diff --git a/config/changelog.yml.example b/config/changelog.yml.example index 5d44b20ba..d6ef55b5c 100644 --- a/config/changelog.yml.example +++ b/config/changelog.yml.example @@ -4,16 +4,16 @@ # Available types for changelog entries available_types: - - feature - - enhancement - - bug-fix - - known-issue - breaking-change + - bug-fix - deprecation - docs + - enhancement + - feature + - known-issue + - other - regression - security - - other # Available subtypes for breaking changes available_subtypes: @@ -34,24 +34,23 @@ available_lifecycles: # Available areas (optional - if not specified, all areas are allowed) available_areas: - - search - - security - - machine-learning - - observability - - index-management + # - Autoscaling + # - Search + # - Security + # - Watcher # Add more areas as needed # Available products (optional - if not specified, all products are allowed) available_products: - - elasticsearch - - kibana - - apm - - beats - - elastic-agent - - fleet - - cloud-hosted - - cloud-serverless - - cloud-enterprise + # - elasticsearch + # - kibana + # - apm + # - beats + # - elastic-agent + # - fleet + # - cloud-hosted + # - cloud-serverless + # - cloud-enterprise # Add more products as needed # GitHub label mappings (optional - used when --pr option is specified) @@ -59,20 +58,36 @@ available_products: # When a PR has a label that matches a key, the corresponding type value is used label_to_type: # Example mappings - customize based on your label naming conventions - # "type:feature": feature - # "type:bug": bug-fix - # "type:enhancement": enhancement - # "type:breaking": breaking-change - # "type:security": security + # ">breaking": breaking-change + # ">bug": bug-fix + # ">docs": docs + # ">enhancement": enhancement + # ">feature": feature # Maps GitHub PR labels to changelog area values # Multiple labels can map to the same area, and a single label can map to multiple areas (comma-separated) label_to_areas: # Example mappings - customize based on your label naming conventions - # "area:search": search - # "area:security": security - # "area:ml": machine-learning - # "area:observability": observability - # "area:index": index-management - # "area:multiple": "search, security" # Multiple areas comma-separated + # ":Distributed Coordination/Autoscaling": Autoscaling + # ":Search/Search": Search + # ":Security/Security": Security + # ":Data Management/Watcher": Watcher + # "area:multiple": "Search, Security" # Multiple areas comma-separated + +# Render blockers (optional - used by the "docs-builder changelog render" command) +# Changelogs matching the specified products and areas/types will be commented out in rendered output files +# Dictionary key can be a single product ID or comma-separated product IDs (e.g., "elasticsearch, cloud-serverless") +# Dictionary value contains areas and/or types that should be blocked for those products +render_blockers: + # Multiple products (comma-separated) with areas and types that should be blocked + "cloud-hosted, cloud-serverless": + areas: # List of area values that should be blocked (commented out) during render + - Autoscaling + - Watcher + types: # List of type values that should be blocked (commented out) during render + - docs + # Single product with areas that should be blocked + elasticsearch: + areas: + - Security diff --git a/docs/cli/release/changelog-render.md b/docs/cli/release/changelog-render.md index 128778251..437483fbe 100644 --- a/docs/cli/release/changelog-render.md +++ b/docs/cli/release/changelog-render.md @@ -45,3 +45,11 @@ docs-builder changelog render [options...] [-h|--help] : When specifying feature IDs directly, provide comma-separated values. : When specifying a file path, provide a single value that points to a newline-delimited file. The file should contain one feature ID per line. : Entries with matching `feature-id` values will be commented out in the markdown output and a warning will be emitted. + +`--config ` +: Optional: Path to the changelog.yml configuration file. +: Defaults to `docs/changelog.yml`. +: This configuration file is where the command looks for `render_blockers` details. + +You can configure `render_blockers` in your `changelog.yml` configuration file to automatically block changelog entries from being rendered based on their products, areas, and/or types. +For more information, refer to [](/contribute/changelog.md#render-blockers). diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index 723cd1e35..b4d874185 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -85,6 +85,9 @@ If a configuration file exists, the command validates all its values before gene - If the configuration file contains `lifecycle`, `product`, `subtype`, or `type` values that don't match the values in `products.yml` and `ChangelogConfiguration.cs`, validation fails. The changelog file is not created. - If the configuration file contains `areas` values and they don't match what you specify in the `--areas` command option, validation fails. The changelog file is not created. +The `available_types`, `available_subtypes`, and `available_lifecycles` fields are optional in the configuration file. +If not specified, all default values from `ChangelogConfiguration.cs` are used. + ### GitHub label mappings You can optionally add `label_to_type` and `label_to_areas` mappings in your changelog configuration. @@ -92,6 +95,48 @@ When you run the command with the `--pr` option, it can use these mappings to fi Refer to [changelog.yml.example](https://github.com/elastic/docs-builder/blob/main/config/changelog.yml.example). +### Render blockers [render-blockers] + +You can optionally add `render_blockers` in your changelog configuration to block specific changelog entries from being rendered in markdown output files. +When you run the `docs-builder changelog render` command, changelog entries that match the specified products and areas/types will be commented out in the markdown output. + +By default, the `docs-builder changelog render` command checks the following path: `docs/changelog.yml`. +You can specify a different path with the `--config` command option. + +The `render_blockers` configuration uses a dictionary format where: + +- The key can be a single product ID or comma-separated product IDs (e.g., `"elasticsearch, cloud-serverless"`) +- The value contains `areas` and/or `types` that should be blocked for those products + +An entry is blocked if any product in the changelog entry matches any product key in `render_blockers` AND (any area matches OR any type matches). +If a changelog entry has multiple products, all matching products in `render_blockers` are checked. + +The `types` values in `render_blockers` must exist in the `available_types` list (or in the default types if `available_types` is not specified). + +Example configuration: + +```yaml +render_blockers: + "cloud-hosted, cloud-serverless": + areas: # List of area values that should be blocked (commented out) during render + - Autoscaling + - Watcher + types: # List of type values that should be blocked (commented out) during render + - docs + elasticsearch: # Another single product case + areas: + - Security +``` + +When rendering, entries with: + +- Product `cloud-hosted` or `cloud-serverless` AND (area `Autoscaling` or `Watcher` OR type `docs`) will be commented out +- Product `elasticsearch` AND area `Security` will be commented out + +The command will emit warnings indicating which changelog entries were commented out and why. + +Refer to [changelog.yml.example](https://github.com/elastic/docs-builder/blob/main/config/changelog.yml.example). + ## Create bundles [changelog-bundle] You can use the `docs-builder changelog bundle` command to create a YAML file that lists multiple changelogs. @@ -261,6 +306,7 @@ Options: --subsections Optional: Group entries by area/component in subsections. Defaults to false --hide-private-links Optional: Hide private links by commenting them out in the markdown output. Defaults to false --hide-features Filter by feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. Entries with matching feature-id values will be commented out in the markdown output. [Default: null] + --config Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' [Default: null] ``` Before you can use this command you must create changelog files and collect them into bundles. diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs index 90f08db0e..61c0c0aa7 100644 --- a/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs @@ -57,6 +57,31 @@ public class ChangelogConfiguration /// public Dictionary? LabelToAreas { get; set; } + /// + /// Configuration for blocking changelogs from being rendered (commented out in markdown output) + /// Dictionary key can be a single product ID or comma-separated product IDs (e.g., "elasticsearch, cloud-serverless") + /// Dictionary value contains areas and/or types that should be blocked for those products + /// Changelogs matching any product key and any area/type in the corresponding entry will be commented out + /// + public Dictionary? RenderBlockers { get; set; } + public static ChangelogConfiguration Default => new(); } +/// +/// Configuration entry for blocking changelogs during render +/// +public class RenderBlockersEntry +{ + /// + /// List of area values that should be blocked (commented out) during render + /// + public List? Areas { get; set; } + + /// + /// List of type values that should be blocked (commented out) during render + /// Types must exist in the available_types list (or default AvailableTypes if not specified) + /// + public List? Types { get; set; } +} + diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogRenderInput.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogRenderInput.cs index 2daa7d569..0d726761d 100644 --- a/src/services/Elastic.Documentation.Services/Changelog/ChangelogRenderInput.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogRenderInput.cs @@ -15,5 +15,6 @@ public class ChangelogRenderInput public bool Subsections { get; set; } public bool HidePrivateLinks { get; set; } public string[]? HideFeatures { get; set; } + public string? Config { get; set; } } diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogYamlStaticContext.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogYamlStaticContext.cs index 2dfb04ff8..3785b2f64 100644 --- a/src/services/Elastic.Documentation.Services/Changelog/ChangelogYamlStaticContext.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogYamlStaticContext.cs @@ -10,6 +10,7 @@ namespace Elastic.Documentation.Services.Changelog; [YamlSerializable(typeof(ChangelogData))] [YamlSerializable(typeof(ProductInfo))] [YamlSerializable(typeof(ChangelogConfiguration))] +[YamlSerializable(typeof(RenderBlockersEntry))] [YamlSerializable(typeof(BundledChangelogData))] [YamlSerializable(typeof(BundledProduct))] [YamlSerializable(typeof(BundledEntry))] diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index 2084c0ee6..dd541b6af 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -235,7 +235,7 @@ Cancel ctx } } - private async Task LoadChangelogConfiguration( + internal async Task LoadChangelogConfiguration( IDiagnosticsCollector collector, string? configPath, Cancel ctx @@ -264,25 +264,68 @@ Cancel ctx var defaultConfig = ChangelogConfiguration.Default; var validProductIds = configurationContext.ProductsConfiguration.Products.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase); - // Validate available_types - foreach (var type in config.AvailableTypes.Where(t => !defaultConfig.AvailableTypes.Contains(t))) + // If available_types is not specified or empty, use defaults + if (config.AvailableTypes == null || config.AvailableTypes.Count == 0) { - collector.EmitError(finalConfigPath, $"Type '{type}' in changelog.yml is not in the list of available types. Available types: {string.Join(", ", defaultConfig.AvailableTypes)}"); - return null; + config.AvailableTypes = defaultConfig.AvailableTypes.ToList(); + } + else + { + // Validate available_types - must be subset of defaults + foreach (var type in config.AvailableTypes.Where(t => !defaultConfig.AvailableTypes.Contains(t))) + { + collector.EmitError(finalConfigPath, $"Type '{type}' in changelog.yml is not in the list of available types. Available types: {string.Join(", ", defaultConfig.AvailableTypes)}"); + return null; + } + } + + // If available_subtypes is not specified or empty, use defaults + if (config.AvailableSubtypes == null || config.AvailableSubtypes.Count == 0) + { + config.AvailableSubtypes = defaultConfig.AvailableSubtypes.ToList(); + } + else + { + // Validate available_subtypes - must be subset of defaults + foreach (var subtype in config.AvailableSubtypes.Where(s => !defaultConfig.AvailableSubtypes.Contains(s))) + { + collector.EmitError(finalConfigPath, $"Subtype '{subtype}' in changelog.yml is not in the list of available subtypes. Available subtypes: {string.Join(", ", defaultConfig.AvailableSubtypes)}"); + return null; + } } - // Validate available_subtypes - foreach (var subtype in config.AvailableSubtypes.Where(s => !defaultConfig.AvailableSubtypes.Contains(s))) + // If available_lifecycles is not specified or empty, use defaults + if (config.AvailableLifecycles == null || config.AvailableLifecycles.Count == 0) + { + config.AvailableLifecycles = defaultConfig.AvailableLifecycles.ToList(); + } + else { - collector.EmitError(finalConfigPath, $"Subtype '{subtype}' in changelog.yml is not in the list of available subtypes. Available subtypes: {string.Join(", ", defaultConfig.AvailableSubtypes)}"); - return null; + // Validate available_lifecycles - must be subset of defaults + foreach (var lifecycle in config.AvailableLifecycles.Where(l => !defaultConfig.AvailableLifecycles.Contains(l))) + { + collector.EmitError(finalConfigPath, $"Lifecycle '{lifecycle}' in changelog.yml is not in the list of available lifecycles. Available lifecycles: {string.Join(", ", defaultConfig.AvailableLifecycles)}"); + return null; + } } - // Validate available_lifecycles - foreach (var lifecycle in config.AvailableLifecycles.Where(l => !defaultConfig.AvailableLifecycles.Contains(l))) + // Validate render_blockers types against available_types + if (config.RenderBlockers != null) { - collector.EmitError(finalConfigPath, $"Lifecycle '{lifecycle}' in changelog.yml is not in the list of available lifecycles. Available lifecycles: {string.Join(", ", defaultConfig.AvailableLifecycles)}"); - return null; + foreach (var (productKey, blockersEntry) in config.RenderBlockers) + { + if (blockersEntry?.Types != null && blockersEntry.Types.Count > 0) + { + foreach (var type in blockersEntry.Types) + { + if (!config.AvailableTypes.Contains(type)) + { + collector.EmitError(finalConfigPath, $"Type '{type}' in render_blockers for '{productKey}' is not in the list of available types. Available types: {string.Join(", ", config.AvailableTypes)}"); + return null; + } + } + } + } } // Validate available_products (if specified) - must be from products.yml @@ -1473,16 +1516,21 @@ Cancel ctx } // Merge phase: Now that validation passed, load and merge all bundles - var allResolvedEntries = new List<(ChangelogData entry, string repo)>(); + var allResolvedEntries = new List<(ChangelogData entry, string repo, HashSet bundleProductIds)>(); var allProducts = new HashSet<(string product, string target)>(); foreach (var (bundledData, bundleInput, bundleDirectory) in bundleDataList) { // Collect products from this bundle + var bundleProductIds = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var product in bundledData.Products) { var target = product.Target ?? string.Empty; _ = allProducts.Add((product.Product, target)); + if (!string.IsNullOrWhiteSpace(product.Product)) + { + _ = bundleProductIds.Add(product.Product); + } } var repo = bundleInput.Repo ?? defaultRepo; @@ -1529,7 +1577,7 @@ Cancel ctx if (entryData != null) { - allResolvedEntries.Add((entryData, repo)); + allResolvedEntries.Add((entryData, repo, bundleProductIds)); } } } @@ -1571,6 +1619,20 @@ Cancel ctx // Convert title to slug format for folder names and anchors (lowercase, dashes instead of spaces) var titleSlug = TitleToSlug(title); + // Load changelog configuration to check for render_blockers + var config = await LoadChangelogConfiguration(collector, input.Config, ctx); + if (config == null) + { + collector.EmitError(string.Empty, "Failed to load changelog configuration"); + return false; + } + + // Extract render blockers from configuration + // RenderBlockers is a Dictionary where: + // - Key can be a single product ID or comma-separated product IDs (e.g., "elasticsearch, cloud-serverless") + // - Value is a RenderBlockersEntry containing areas and/or types that should be blocked for those products + var renderBlockers = config.RenderBlockers; + // Load feature IDs to hide - check if --hide-features contains a file path or a list of feature IDs var featureIdsToHide = new HashSet(StringComparer.OrdinalIgnoreCase); if (input.HideFeatures is { Length: > 0 }) @@ -1668,7 +1730,7 @@ Cancel ctx // Track hidden entries for warnings var hiddenEntries = new List<(string title, string featureId)>(); - foreach (var (entry, _) in allResolvedEntries) + foreach (var (entry, _, _) in allResolvedEntries) { if (!string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId)) { @@ -1685,17 +1747,48 @@ Cancel ctx } } + // Check entries against render blockers and track blocked entries + // render_blockers matches against bundle products, not individual entry products + var blockedEntries = new List<(string title, List reasons)>(); + foreach (var (entry, _, bundleProductIds) in allResolvedEntries) + { + var reasons = new List(); + var isBlocked = ShouldBlockEntry(entry, bundleProductIds, renderBlockers, out var blockReasons); + if (isBlocked) + { + blockedEntries.Add((entry.Title ?? "Unknown", blockReasons)); + } + } + + // Emit warnings for blocked entries + if (blockedEntries.Count > 0) + { + foreach (var (entryTitle, reasons) in blockedEntries) + { + var reasonsText = string.Join(" and ", reasons); + collector.EmitWarning(string.Empty, $"Changelog entry '{entryTitle}' will be commented out in markdown output because it matches render_blockers: {reasonsText}"); + } + } + + // Create mapping from entries to their bundle product IDs for render_blockers checking + // Use a custom comparer for reference equality since entries are objects + var entryToBundleProducts = new Dictionary>(); + foreach (var (entry, _, bundleProductIds) in allResolvedEntries) + { + entryToBundleProducts[entry] = bundleProductIds; + } + // Render markdown files (use first repo found, or default) var repoForRendering = allResolvedEntries.Count > 0 ? allResolvedEntries[0].repo : defaultRepo; // Render index.md (features, enhancements, bug fixes, security) - await RenderIndexMarkdown(collector, outputDir, title, titleSlug, repoForRendering, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, input.HidePrivateLinks, featureIdsToHide, ctx); + await RenderIndexMarkdown(collector, outputDir, title, titleSlug, repoForRendering, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, input.HidePrivateLinks, featureIdsToHide, renderBlockers, entryToBundleProducts, ctx); // Render breaking-changes.md - await RenderBreakingChangesMarkdown(collector, outputDir, title, titleSlug, repoForRendering, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, input.HidePrivateLinks, featureIdsToHide, ctx); + await RenderBreakingChangesMarkdown(collector, outputDir, title, titleSlug, repoForRendering, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, input.HidePrivateLinks, featureIdsToHide, renderBlockers, entryToBundleProducts, ctx); // Render deprecations.md - await RenderDeprecationsMarkdown(collector, outputDir, title, titleSlug, repoForRendering, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, input.HidePrivateLinks, featureIdsToHide, ctx); + await RenderDeprecationsMarkdown(collector, outputDir, title, titleSlug, repoForRendering, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, input.HidePrivateLinks, featureIdsToHide, renderBlockers, entryToBundleProducts, ctx); _logger.LogInformation("Rendered changelog markdown files to {OutputDir}", outputDir); @@ -1735,6 +1828,8 @@ private async Task RenderIndexMarkdown( bool subsections, bool hidePrivateLinks, HashSet featureIdsToHide, + Dictionary? renderBlockers, + Dictionary> entryToBundleProducts, Cancel ctx ) { @@ -1782,7 +1877,7 @@ Cancel ctx { sb.AppendLine(CultureInfo.InvariantCulture, $"### Features and enhancements [{repo}-{titleSlug}-features-enhancements]"); var combined = features.Concat(enhancements).ToList(); - RenderEntriesByArea(sb, combined, repo, subsections, hidePrivateLinks, featureIdsToHide); + RenderEntriesByArea(sb, combined, repo, subsections, hidePrivateLinks, featureIdsToHide, renderBlockers, entryToBundleProducts); } if (security.Count > 0 || bugFixes.Count > 0) @@ -1790,7 +1885,7 @@ Cancel ctx sb.AppendLine(); sb.AppendLine(CultureInfo.InvariantCulture, $"### Fixes [{repo}-{titleSlug}-fixes]"); var combined = security.Concat(bugFixes).ToList(); - RenderEntriesByArea(sb, combined, repo, subsections, hidePrivateLinks, featureIdsToHide); + RenderEntriesByArea(sb, combined, repo, subsections, hidePrivateLinks, featureIdsToHide, renderBlockers, entryToBundleProducts); } } else @@ -1821,6 +1916,8 @@ private async Task RenderBreakingChangesMarkdown( bool subsections, bool hidePrivateLinks, HashSet featureIdsToHide, + Dictionary? renderBlockers, + Dictionary> entryToBundleProducts, Cancel ctx ) { @@ -1843,7 +1940,9 @@ Cancel ctx foreach (var entry in areaGroup) { - var shouldHide = !string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId); + var bundleProductIds = entryToBundleProducts.GetValueOrDefault(entry, new HashSet(StringComparer.OrdinalIgnoreCase)); + var shouldHide = (!string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId)) || + ShouldBlockEntry(entry, bundleProductIds, renderBlockers, out _); sb.AppendLine(); if (shouldHide) @@ -1944,6 +2043,8 @@ private async Task RenderDeprecationsMarkdown( bool subsections, bool hidePrivateLinks, HashSet featureIdsToHide, + Dictionary? renderBlockers, + Dictionary> entryToBundleProducts, Cancel ctx ) { @@ -1966,7 +2067,9 @@ Cancel ctx foreach (var entry in areaGroup) { - var shouldHide = !string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId); + var bundleProductIds = entryToBundleProducts.GetValueOrDefault(entry, new HashSet(StringComparer.OrdinalIgnoreCase)); + var shouldHide = (!string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId)) || + ShouldBlockEntry(entry, bundleProductIds, renderBlockers, out _); sb.AppendLine(); if (shouldHide) @@ -2055,7 +2158,7 @@ Cancel ctx } [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0058:Expression value is never used", Justification = "StringBuilder methods return builder for chaining")] - private void RenderEntriesByArea(StringBuilder sb, List entries, string repo, bool subsections, bool hidePrivateLinks, HashSet featureIdsToHide) + private void RenderEntriesByArea(StringBuilder sb, List entries, string repo, bool subsections, bool hidePrivateLinks, HashSet featureIdsToHide, Dictionary? renderBlockers, Dictionary> entryToBundleProducts) { var groupedByArea = entries.GroupBy(e => GetComponent(e)).ToList(); foreach (var areaGroup in groupedByArea) @@ -2069,7 +2172,9 @@ private void RenderEntriesByArea(StringBuilder sb, List entries, foreach (var entry in areaGroup) { - var shouldHide = !string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId); + var bundleProductIds = entryToBundleProducts.GetValueOrDefault(entry, new HashSet(StringComparer.OrdinalIgnoreCase)); + var shouldHide = (!string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId)) || + ShouldBlockEntry(entry, bundleProductIds, renderBlockers, out _); if (shouldHide) { @@ -2170,6 +2275,119 @@ private void RenderEntriesByArea(StringBuilder sb, List entries, } } + /// + /// Checks if an entry should be blocked based on render_blockers configuration. + /// RenderBlockers is a Dictionary where: + /// - Key can be a single product ID or comma-separated product IDs (e.g., "elasticsearch, cloud-serverless") + /// - Value is a RenderBlockersEntry containing areas and/or types that should be blocked for those products + /// An entry is blocked if ANY product in the bundle matches ANY product key AND (ANY area matches OR ANY type matches). + /// Note: render_blockers matches against bundle products, not individual entry products. + /// + private static bool ShouldBlockEntry(ChangelogData entry, HashSet bundleProductIds, Dictionary? renderBlockers, out List reasons) + { + reasons = []; + if (renderBlockers == null || renderBlockers.Count == 0) + { + return false; + } + + // Bundle must have products to be blocked + if (bundleProductIds == null || bundleProductIds.Count == 0) + { + return false; + } + + // Extract area values from entry (case-insensitive comparison) + var entryAreas = entry.Areas != null && entry.Areas.Count > 0 + ? entry.Areas + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Select(a => a!) + .ToHashSet(StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + + // Extract type from entry (case-insensitive comparison) + var entryType = !string.IsNullOrWhiteSpace(entry.Type) + ? entry.Type + : null; + + // Check each render_blockers entry + foreach (var (productKey, blockersEntry) in renderBlockers) + { + if (blockersEntry == null) + { + continue; + } + + // Parse product key - can be comma-separated (e.g., "elasticsearch, cloud-serverless") + var productKeys = productKey + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Check if any product in the bundle matches any product in the key + var matchingProducts = bundleProductIds.Intersect(productKeys, StringComparer.OrdinalIgnoreCase).ToList(); + if (matchingProducts.Count == 0) + { + continue; + } + + var isBlocked = false; + var blockReasons = new List(); + + // Check areas if specified + if (blockersEntry.Areas != null && blockersEntry.Areas.Count > 0 && entryAreas.Count > 0) + { + var matchingAreas = entryAreas.Intersect(blockersEntry.Areas, StringComparer.OrdinalIgnoreCase).ToList(); + if (matchingAreas.Count > 0) + { + isBlocked = true; + foreach (var product in matchingProducts) + { + foreach (var area in matchingAreas) + { + var reason = $"product '{product}' with area '{area}'"; + if (!blockReasons.Contains(reason)) + { + blockReasons.Add(reason); + } + } + } + } + } + + // Check types if specified + if (blockersEntry.Types != null && blockersEntry.Types.Count > 0 && !string.IsNullOrWhiteSpace(entryType)) + { + var matchingTypes = blockersEntry.Types + .Where(t => string.Equals(t, entryType, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (matchingTypes.Count > 0) + { + isBlocked = true; + foreach (var product in matchingProducts) + { + foreach (var type in matchingTypes) + { + var reason = $"product '{product}' with type '{type}'"; + if (!blockReasons.Contains(reason)) + { + blockReasons.Add(reason); + } + } + } + } + } + + if (isBlocked) + { + reasons.AddRange(blockReasons); + return true; + } + } + + return false; + } + private static string GetComponent(ChangelogData entry) { // Map areas (list) to component (string) - use first area or empty string diff --git a/src/services/Elastic.Documentation.Services/Elastic.Documentation.Services.csproj b/src/services/Elastic.Documentation.Services/Elastic.Documentation.Services.csproj index 0494a04d1..96209f37e 100644 --- a/src/services/Elastic.Documentation.Services/Elastic.Documentation.Services.csproj +++ b/src/services/Elastic.Documentation.Services/Elastic.Documentation.Services.csproj @@ -6,6 +6,12 @@ enable + + + <_Parameter1>Elastic.Documentation.Services.Tests + + + diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 61f496195..c61130533 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -193,6 +193,7 @@ public async Task Render( bool subsections = false, bool hidePrivateLinks = false, string[]? hideFeatures = null, + string? config = null, Cancel ctx = default ) { @@ -229,7 +230,8 @@ public async Task Render( Title = title, Subsections = subsections, HidePrivateLinks = hidePrivateLinks, - HideFeatures = allFeatureIds.Count > 0 ? allFeatureIds.ToArray() : null + HideFeatures = allFeatureIds.Count > 0 ? allFeatureIds.ToArray() : null, + Config = config }; serviceInvoker.AddCommand(service, renderInput, diff --git a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs index ddd693a62..be866bfc1 100644 --- a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs +++ b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs @@ -2908,6 +2908,958 @@ public async Task RenderChangelogs_WithHideFeatures_CaseInsensitive_MatchesFeatu indexContent.Should().Contain("% * Hidden feature"); } + [Fact] + public async Task RenderChangelogs_WithRenderBlockers_CommentsOutMatchingEntries() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create changelog that should be blocked (elasticsearch + search area) + var changelog1 = """ + title: Blocked feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + areas: + - search + pr: https://github.com/elastic/elasticsearch/pull/100 + description: This feature should be blocked + """; + + // Create changelog that should NOT be blocked (elasticsearch but different area) + var changelog2 = """ + title: Visible feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + areas: + - observability + 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 config file with render_blockers in docs/ subdirectory + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var docsDir = fileSystem.Path.Combine(configDir, "docs"); + fileSystem.Directory.CreateDirectory(docsDir); + var configPath = fileSystem.Path.Combine(docsDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + available_subtypes: [] + available_lifecycles: + - ga + render_blockers: + elasticsearch: + areas: + - search + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, 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"); + 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); + + // Set current directory to where config file is located so it can be found + var originalDir = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(configDir); + + var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new ChangelogRenderInput + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0" + }; + + // Act + var result = await service.RenderChangelogs(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + _collector.Warnings.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Warning && + d.Message.Contains("Blocked feature") && + d.Message.Contains("render_blockers") && + d.Message.Contains("product 'elasticsearch'") && + d.Message.Contains("area 'search'")); + + 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 feature"); + // Visible entry should not be commented + indexContent.Should().Contain("* Visible feature"); + indexContent.Should().NotContain("% * Visible feature"); + } + finally + { + Directory.SetCurrentDirectory(originalDir); + } + } + + [Fact] + public async Task RenderChangelogs_WithRenderBlockers_CommaSeparatedProducts_CommentsOutMatchingEntries() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create changelog with cloud-serverless product that should be blocked + var changelog1 = """ + title: Blocked cloud feature + type: feature + products: + - product: cloud-serverless + target: 2025-12-02 + areas: + - security + pr: https://github.com/elastic/cloud-serverless/pull/100 + description: This feature should be blocked + """; + + // Create changelog with elasticsearch product that should also be blocked + var changelog2 = """ + title: Blocked elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + areas: + - security + pr: https://github.com/elastic/elasticsearch/pull/101 + description: This feature should also be blocked + """; + + var changelogFile1 = fileSystem.Path.Combine(changelogDir, "1755268130-cloud-blocked.yaml"); + var changelogFile2 = fileSystem.Path.Combine(changelogDir, "1755268140-es-blocked.yaml"); + await fileSystem.File.WriteAllTextAsync(changelogFile1, changelog1, TestContext.Current.CancellationToken); + await fileSystem.File.WriteAllTextAsync(changelogFile2, changelog2, TestContext.Current.CancellationToken); + + // Create config file with render_blockers using comma-separated products in docs/ subdirectory + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var docsDir = fileSystem.Path.Combine(configDir, "docs"); + fileSystem.Directory.CreateDirectory(docsDir); + var configPath = fileSystem.Path.Combine(docsDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + available_subtypes: [] + available_lifecycles: + - ga + render_blockers: + "elasticsearch, cloud-serverless": + areas: + - security + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, 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"); + var bundleContent = $""" + products: + - product: elasticsearch + target: 9.2.0 + - product: cloud-serverless + target: 2025-12-02 + entries: + - file: + name: 1755268130-cloud-blocked.yaml + checksum: {ComputeSha1(changelog1)} + - file: + name: 1755268140-es-blocked.yaml + checksum: {ComputeSha1(changelog2)} + """; + await fileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + // Set current directory to where config file is located so it can be found + var originalDir = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(configDir); + + var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new ChangelogRenderInput + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0" + }; + + // Act + var result = await service.RenderChangelogs(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + _collector.Warnings.Should().BeGreaterThan(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); + // Both entries should be commented out + indexContent.Should().Contain("% * Blocked cloud feature"); + indexContent.Should().Contain("% * Blocked elasticsearch feature"); + } + finally + { + Directory.SetCurrentDirectory(originalDir); + } + } + + [Fact] + public async Task RenderChangelogs_WithRenderBlockers_MultipleProductsInEntry_ChecksAllProducts() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create changelog with multiple products - one matches render_blockers + var changelog = """ + title: Multi-product feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + - product: kibana + target: 9.2.0 + areas: + - search + pr: https://github.com/elastic/elasticsearch/pull/100 + description: This feature should be blocked because elasticsearch matches + """; + + var changelogFile = fileSystem.Path.Combine(changelogDir, "1755268130-multi-product.yaml"); + await fileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); + + // Create config file with render_blockers for elasticsearch only in docs/ subdirectory + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var docsDir = fileSystem.Path.Combine(configDir, "docs"); + fileSystem.Directory.CreateDirectory(docsDir); + var configPath = fileSystem.Path.Combine(docsDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + available_subtypes: [] + available_lifecycles: + - ga + render_blockers: + elasticsearch: + areas: + - search + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, 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"); + var bundleContent = $""" + products: + - product: elasticsearch + target: 9.2.0 + - product: kibana + target: 9.2.0 + entries: + - file: + name: 1755268130-multi-product.yaml + checksum: {ComputeSha1(changelog)} + """; + await fileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + // Set current directory to where config file is located so it can be found + var originalDir = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(configDir); + + var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new ChangelogRenderInput + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0" + }; + + // Act + var result = await service.RenderChangelogs(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + _collector.Warnings.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Warning && + d.Message.Contains("Multi-product feature") && + d.Message.Contains("product 'elasticsearch'")); + + 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); + // Should be blocked because elasticsearch matches, even though kibana doesn't + indexContent.Should().Contain("% * Multi-product feature"); + } + finally + { + Directory.SetCurrentDirectory(originalDir); + } + } + + [Fact] + public async Task RenderChangelogs_WithRenderBlockers_TypeBlocking_CommentsOutMatchingEntries() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create changelog that should be blocked (elasticsearch + feature type, blocked by type) + var changelog1 = """ + title: Blocked feature by type + type: feature + products: + - product: elasticsearch + target: 9.2.0 + pr: https://github.com/elastic/elasticsearch/pull/100 + description: This feature should be blocked by type + """; + + // Create changelog that should NOT be blocked (elasticsearch but different type) + var changelog2 = """ + title: Visible enhancement + type: enhancement + products: + - product: elasticsearch + target: 9.2.0 + pr: https://github.com/elastic/elasticsearch/pull/101 + description: This enhancement 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 config file with render_blockers blocking docs type + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var docsDir = fileSystem.Path.Combine(configDir, "docs"); + fileSystem.Directory.CreateDirectory(docsDir); + var configPath = fileSystem.Path.Combine(docsDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + - enhancement + available_subtypes: [] + available_lifecycles: + - ga + render_blockers: + elasticsearch: + types: + - feature + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, 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"); + 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); + + // Set current directory to where config file is located so it can be found + var originalDir = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(configDir); + + var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new ChangelogRenderInput + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0" + }; + + // Act + var result = await service.RenderChangelogs(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + _collector.Warnings.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Warning && + d.Message.Contains("Blocked feature by type") && + d.Message.Contains("render_blockers") && + d.Message.Contains("product 'elasticsearch'") && + d.Message.Contains("type 'feature'")); + + 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 feature by type"); + // Visible entry should not be commented + indexContent.Should().Contain("* Visible enhancement"); + indexContent.Should().NotContain("% * Visible enhancement"); + } + finally + { + Directory.SetCurrentDirectory(originalDir); + } + } + + [Fact] + public async Task RenderChangelogs_WithRenderBlockers_AreasAndTypes_CommentsOutMatchingEntries() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create changelog that should be blocked by area (elasticsearch + search area) + var changelog1 = """ + title: Blocked by area + type: feature + products: + - product: elasticsearch + target: 9.2.0 + areas: + - search + pr: https://github.com/elastic/elasticsearch/pull/100 + description: This should be blocked by area + """; + + // Create changelog that should be blocked by type (elasticsearch + enhancement type, blocked by type) + var changelog2 = """ + title: Blocked by type + type: enhancement + products: + - product: elasticsearch + target: 9.2.0 + pr: https://github.com/elastic/elasticsearch/pull/101 + description: This should be blocked by type + """; + + // Create changelog that should NOT be blocked + var changelog3 = """ + title: Visible feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + areas: + - observability + pr: https://github.com/elastic/elasticsearch/pull/102 + description: This should be visible + """; + + var changelogFile1 = fileSystem.Path.Combine(changelogDir, "1755268130-area-blocked.yaml"); + var changelogFile2 = fileSystem.Path.Combine(changelogDir, "1755268140-type-blocked.yaml"); + var changelogFile3 = fileSystem.Path.Combine(changelogDir, "1755268150-visible.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 config file with render_blockers blocking both areas and types + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var docsDir = fileSystem.Path.Combine(configDir, "docs"); + fileSystem.Directory.CreateDirectory(docsDir); + var configPath = fileSystem.Path.Combine(docsDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + - enhancement + available_subtypes: [] + available_lifecycles: + - ga + render_blockers: + elasticsearch: + areas: + - search + types: + - enhancement + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, 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"); + var bundleContent = $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: 1755268130-area-blocked.yaml + checksum: {ComputeSha1(changelog1)} + - file: + name: 1755268140-type-blocked.yaml + checksum: {ComputeSha1(changelog2)} + - file: + name: 1755268150-visible.yaml + checksum: {ComputeSha1(changelog3)} + """; + await fileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + // Set current directory to where config file is located so it can be found + var originalDir = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(configDir); + + var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new ChangelogRenderInput + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0" + }; + + // Act + var result = await service.RenderChangelogs(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + _collector.Warnings.Should().BeGreaterThan(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); + // Both blocked entries should be commented out + indexContent.Should().Contain("% * Blocked by area"); + indexContent.Should().Contain("% * Blocked by type"); + // Visible entry should not be commented + indexContent.Should().Contain("* Visible feature"); + indexContent.Should().NotContain("% * Visible feature"); + } + finally + { + Directory.SetCurrentDirectory(originalDir); + } + } + + [Fact] + public async Task RenderChangelogs_WithRenderBlockers_UsesBundleProductsNotEntryProducts() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create changelog with elasticsearch product and search area + // But bundle has kibana product - should NOT be blocked because render_blockers matches against bundle products + var changelog1 = """ + title: Entry with elasticsearch but bundle has kibana + type: feature + products: + - product: elasticsearch + target: 9.2.0 + areas: + - search + pr: https://github.com/elastic/elasticsearch/pull/100 + description: This should NOT be blocked because bundle product is kibana + """; + + var changelogFile1 = fileSystem.Path.Combine(changelogDir, "1755268130-test.yaml"); + await fileSystem.File.WriteAllTextAsync(changelogFile1, changelog1, TestContext.Current.CancellationToken); + + // Create config file with render_blockers blocking elasticsearch + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var docsDir = fileSystem.Path.Combine(configDir, "docs"); + fileSystem.Directory.CreateDirectory(docsDir); + var configPath = fileSystem.Path.Combine(docsDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + available_subtypes: [] + available_lifecycles: + - ga + render_blockers: + elasticsearch: + areas: + - search + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + // Create bundle file with kibana product (not elasticsearch) + var bundleDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = fileSystem.Path.Combine(bundleDir, "bundle.yaml"); + var bundleContent = $""" + products: + - product: kibana + target: 9.2.0 + entries: + - file: + name: 1755268130-test.yaml + checksum: {ComputeSha1(changelog1)} + """; + await fileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + // Set current directory to where config file is located so it can be found + var originalDir = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(configDir); + + var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new ChangelogRenderInput + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0" + }; + + // Act + var result = await service.RenderChangelogs(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + // Should have no warnings because entry is NOT blocked (bundle product is kibana, not elasticsearch) + _collector.Warnings.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); + // Entry should NOT be commented out because bundle product is kibana, not elasticsearch + indexContent.Should().Contain("* Entry with elasticsearch but bundle has kibana"); + indexContent.Should().NotContain("% * Entry with elasticsearch but bundle has kibana"); + } + finally + { + Directory.SetCurrentDirectory(originalDir); + } + } + + [Fact] + public async Task RenderChangelogs_WithCustomConfigPath_UsesSpecifiedConfigFile() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create changelog that should be blocked (elasticsearch + search area) + var changelog1 = """ + title: Blocked feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + areas: + - search + pr: https://github.com/elastic/elasticsearch/pull/100 + description: This feature should be blocked + """; + + var changelogFile1 = fileSystem.Path.Combine(changelogDir, "1755268130-blocked.yaml"); + await fileSystem.File.WriteAllTextAsync(changelogFile1, changelog1, TestContext.Current.CancellationToken); + + // Create config file in a custom location (not in docs/ subdirectory) + var customConfigDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(customConfigDir); + var customConfigPath = fileSystem.Path.Combine(customConfigDir, "custom-changelog.yml"); + var configContent = """ + available_types: + - feature + available_subtypes: [] + available_lifecycles: + - ga + render_blockers: + elasticsearch: + areas: + - search + """; + await fileSystem.File.WriteAllTextAsync(customConfigPath, configContent, 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"); + var bundleContent = $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: 1755268130-blocked.yaml + checksum: {ComputeSha1(changelog1)} + """; + await fileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + // Don't change directory - use custom config path via Config property + var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new ChangelogRenderInput + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0", + Config = customConfigPath + }; + + // Act + var result = await service.RenderChangelogs(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + _collector.Warnings.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Warning && + d.Message.Contains("Blocked feature") && + d.Message.Contains("render_blockers") && + d.Message.Contains("product 'elasticsearch'") && + d.Message.Contains("area 'search'")); + + 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 feature"); + } + + [Fact] + public async Task LoadChangelogConfiguration_WithoutAvailableTypes_UsesDefaults() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var docsDir = fileSystem.Path.Combine(configDir, "docs"); + fileSystem.Directory.CreateDirectory(docsDir); + var configPath = fileSystem.Path.Combine(docsDir, "changelog.yml"); + // Config without available_types - should use defaults + var configContent = """ + available_subtypes: [] + available_lifecycles: + - ga + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var originalDir = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(configDir); + + // Act + var config = await service.LoadChangelogConfiguration(_collector, null, TestContext.Current.CancellationToken); + + // Assert + config.Should().NotBeNull(); + _collector.Errors.Should().Be(0); + // Should have default types + config!.AvailableTypes.Should().Contain("feature"); + config.AvailableTypes.Should().Contain("bug-fix"); + config.AvailableTypes.Should().Contain("docs"); + } + finally + { + Directory.SetCurrentDirectory(originalDir); + } + } + + [Fact] + public async Task LoadChangelogConfiguration_WithoutAvailableSubtypes_UsesDefaults() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var docsDir = fileSystem.Path.Combine(configDir, "docs"); + fileSystem.Directory.CreateDirectory(docsDir); + var configPath = fileSystem.Path.Combine(docsDir, "changelog.yml"); + // Config without available_subtypes - should use defaults + var configContent = """ + available_types: + - feature + available_lifecycles: + - ga + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var originalDir = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(configDir); + + // Act + var config = await service.LoadChangelogConfiguration(_collector, null, TestContext.Current.CancellationToken); + + // Assert + config.Should().NotBeNull(); + _collector.Errors.Should().Be(0); + // Should have default subtypes + config!.AvailableSubtypes.Should().Contain("api"); + config.AvailableSubtypes.Should().Contain("behavioral"); + } + finally + { + Directory.SetCurrentDirectory(originalDir); + } + } + + [Fact] + public async Task LoadChangelogConfiguration_WithoutAvailableLifecycles_UsesDefaults() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var docsDir = fileSystem.Path.Combine(configDir, "docs"); + fileSystem.Directory.CreateDirectory(docsDir); + var configPath = fileSystem.Path.Combine(docsDir, "changelog.yml"); + // Config without available_lifecycles - should use defaults + var configContent = """ + available_types: + - feature + available_subtypes: [] + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var originalDir = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(configDir); + + // Act + var config = await service.LoadChangelogConfiguration(_collector, null, TestContext.Current.CancellationToken); + + // Assert + config.Should().NotBeNull(); + _collector.Errors.Should().Be(0); + // Should have default lifecycles + config!.AvailableLifecycles.Should().Contain("preview"); + config.AvailableLifecycles.Should().Contain("beta"); + config.AvailableLifecycles.Should().Contain("ga"); + } + finally + { + Directory.SetCurrentDirectory(originalDir); + } + } + + [Fact] + public async Task LoadChangelogConfiguration_WithInvalidRenderBlockersType_ReturnsError() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var docsDir = fileSystem.Path.Combine(configDir, "docs"); + fileSystem.Directory.CreateDirectory(docsDir); + var configPath = fileSystem.Path.Combine(docsDir, "changelog.yml"); + // Config with invalid type in render_blockers + var configContent = """ + available_types: + - feature + - docs + available_subtypes: [] + available_lifecycles: + - ga + render_blockers: + elasticsearch: + types: + - invalid-type + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var originalDir = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(configDir); + + // Act + var config = await service.LoadChangelogConfiguration(_collector, null, TestContext.Current.CancellationToken); + + // Assert + config.Should().BeNull(); + _collector.Errors.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && + d.Message.Contains("Type 'invalid-type' in render_blockers") && + d.Message.Contains("is not in the list of available types")); + } + finally + { + Directory.SetCurrentDirectory(originalDir); + } + } + private static string ComputeSha1(string content) { var bytes = System.Text.Encoding.UTF8.GetBytes(content);