diff --git a/docs/_docset.yml b/docs/_docset.yml index ac622d8b8..16eb28172 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -223,3 +223,9 @@ toc: - folder: baz children: - file: qux.md + - folder: release-notes + children: + - file: index.md + - file: breaking-changes.md + - file: deprecations.md + - file: known-issues.md \ No newline at end of file diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index 576d12e97..bb37163ff 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -16,6 +16,197 @@ Or with a custom bundles folder: ::: ``` +## Options + +The directive supports the following options: + +| Option | Description | Default | +|--------|-------------|---------| +| `:type: value` | Filter entries by type | Excludes separated types | +| `:subsections:` | Group entries by area/component | false | +| `:config: path` | Path to changelog.yml configuration | auto-discover | + +### Example with options + +```markdown +:::{changelog} /path/to/bundles +:type: all +:subsections: +::: +``` + +### Option details + +#### `:type:` + +Controls which entry types are displayed. By default, the directive excludes "separated types" (known issues, breaking changes, and deprecations) which are typically shown on their own dedicated pages. + +| Value | Description | +|-------|-------------| +| (omitted) | Default: shows all types EXCEPT known issues, breaking changes, and deprecations | +| `all` | Shows all entry types including known issues, breaking changes, and deprecations | +| `breaking-change` | Shows only breaking change entries | +| `deprecation` | Shows only deprecation entries | +| `known-issue` | Shows only known issue entries | + +This allows you to create separate pages for different entry types: + +```markdown +# Release Notes + +:::{changelog} +::: +``` + +```markdown +# Breaking Changes + +:::{changelog} +:type: breaking-change +::: +``` + +```markdown +# Known Issues + +:::{changelog} +:type: known-issue +::: +``` + +```markdown +# Deprecations + +:::{changelog} +:type: deprecation +::: +``` + +To show all entries on a single page (previous default behavior): + +```markdown +:::{changelog} +:type: all +::: +``` + +#### `:subsections:` + +When enabled, entries are grouped by their area/component within each section. By default, entries are listed without area grouping (matching CLI behavior). + +#### `:config:` + +Explicit path to a `changelog.yml` configuration file. If not specified, the directive auto-discovers from: +1. `changelog.yml` in the docset root +2. `docs/changelog.yml` relative to docset root + +The configuration can include publish blockers to filter entries by type or area. + +## Filtering entries with publish blockers + +You can filter changelog entries from the rendered output using the `block.publish` configuration in your `changelog.yml` file. This is useful for hiding entries that shouldn't appear in public documentation, such as internal changes or documentation-only updates. + +### Configuration syntax + +Create a `changelog.yml` file in your docset root (or `docs/changelog.yml`): + +```yaml +block: + publish: + types: + - docs # Hide documentation entries + - regression # Hide regression entries + areas: + - Internal # Hide entries with "Internal" area + - Experimental # Hide entries with "Experimental" area +``` + +### Filtering by type + +The `types` list filters entries based on their changelog entry type. Matching is **case-insensitive**. + +| Type | Description | +|------|-------------| +| `feature` | New features | +| `enhancement` | Improvements to existing features | +| `security` | Security advisories and fixes | +| `bug-fix` | Bug fixes | +| `breaking-change` | Breaking changes | +| `deprecation` | Deprecated functionality | +| `known-issue` | Known issues | +| `docs` | Documentation changes | +| `regression` | Regressions | +| `other` | Other changes | + +Example - hide documentation and regression entries: + +```yaml +block: + publish: + types: + - docs + - regression +``` + +### Filtering by area + +The `areas` list filters entries based on their area/component tags. An entry is blocked if **any** of its areas match a blocked area. Matching is **case-insensitive**. + +Example - hide internal and experimental entries: + +```yaml +block: + publish: + areas: + - Internal + - Experimental + - Testing +``` + +### Combining type and area filters + +You can combine both `types` and `areas` filters. An entry is blocked if it matches **either** a blocked type **or** a blocked area. + +```yaml +block: + publish: + types: + - docs + - deprecation + areas: + - Internal +``` + +This configuration will hide: +- All entries with type `docs` or `deprecation` +- All entries with the `Internal` area tag (regardless of type) + +### Example: Cloud Serverless configuration + +For Cloud Serverless releases where you want to hide certain entry types: + +```yaml +# changelog.yml +block: + publish: + types: + - docs # Documentation changes handled separately + - deprecation # Deprecations shown on dedicated page + - known-issue # Known issues shown on dedicated page +``` + +## Private repository link hiding + +PR and issue links are automatically hidden (commented out) for bundles from private repositories. This is determined by checking the `assembler.yml` configuration: + +- Repositories marked with `private: true` in `assembler.yml` will have their links hidden +- For merged bundles (e.g., `elasticsearch+kibana`), links are hidden if ANY component repository is private +- In standalone builds without `assembler.yml`, all links are shown by default + +## Bundle merging + +Bundles with the same target version/date are automatically merged into a single section. This is useful for Cloud Serverless releases where multiple repositories (e.g., Elasticsearch, Kibana) contribute to a single dated release like `2025-08-05`. + ## Default folder structure The directive expects bundles in `changelog/bundles/` relative to the docset root: diff --git a/docs/testing/release-notes/breaking-changes.md b/docs/testing/release-notes/breaking-changes.md new file mode 100644 index 000000000..5c5bed838 --- /dev/null +++ b/docs/testing/release-notes/breaking-changes.md @@ -0,0 +1,5 @@ +# Breaking Changes + +:::{changelog} +:type: breaking-change +::: diff --git a/docs/testing/release-notes/deprecations.md b/docs/testing/release-notes/deprecations.md new file mode 100644 index 000000000..8d07fdd09 --- /dev/null +++ b/docs/testing/release-notes/deprecations.md @@ -0,0 +1,5 @@ +# Deprecations + +:::{changelog} +:type: deprecation +::: diff --git a/docs/testing/release-notes/index.md b/docs/testing/release-notes/index.md new file mode 100644 index 000000000..e284bc49d --- /dev/null +++ b/docs/testing/release-notes/index.md @@ -0,0 +1,4 @@ +# Release Notes + +:::{changelog} +::: diff --git a/docs/testing/release-notes/known-issues.md b/docs/testing/release-notes/known-issues.md new file mode 100644 index 000000000..ecba0f389 --- /dev/null +++ b/docs/testing/release-notes/known-issues.md @@ -0,0 +1,5 @@ +# Known Issues + +:::{changelog} +:type: known-issue +::: diff --git a/src/Elastic.Documentation.Configuration/Changelog/BlockConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BlockConfiguration.cs index 5dff71056..5cccd3e6f 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BlockConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BlockConfiguration.cs @@ -2,63 +2,44 @@ // 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.Documentation.ReleaseNotes; + namespace Elastic.Documentation.Configuration.Changelog; /// -/// Combined block configuration for create and publish blockers +/// Combined block configuration for create and publish blockers. /// public record BlockConfiguration { /// - /// Global labels that block changelog creation + /// Global labels that block changelog creation. /// public IReadOnlyList? Create { get; init; } /// - /// Global configuration for blocking changelog entries from publishing based on type or area + /// Global configuration for blocking changelog entries from publishing based on type or area. /// public PublishBlocker? Publish { get; init; } /// - /// Per-product block overrides (overrides global blockers, does not merge) - /// Keys are product IDs + /// Per-product block overrides (overrides global blockers, does not merge). + /// Keys are product IDs. /// public IReadOnlyDictionary? ByProduct { get; init; } } /// -/// Product-specific blockers +/// Product-specific blockers. /// public record ProductBlockers { /// - /// Labels that block creation for this product (overrides global create blockers) + /// Labels that block creation for this product (overrides global create blockers). /// public IReadOnlyList? Create { get; init; } /// - /// Configuration for blocking changelog entries from publishing based on type or area + /// Configuration for blocking changelog entries from publishing based on type or area. /// public PublishBlocker? Publish { get; init; } } - -/// -/// Configuration for blocking changelog entries from publishing based on type or area -/// -public record PublishBlocker -{ - /// - /// Entry types to block from publishing (e.g., "deprecation", "known-issue") - /// - public IReadOnlyList? Types { get; init; } - - /// - /// Entry areas to block from publishing (e.g., "Internal", "Experimental") - /// - public IReadOnlyList? Areas { get; init; } - - /// - /// Returns true if this blocker has any blocking rules configured - /// - public bool HasBlockingRules => (Types?.Count > 0) || (Areas?.Count > 0); -} diff --git a/src/services/Elastic.Changelog/Serialization/BundleYaml.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs similarity index 65% rename from src/services/Elastic.Changelog/Serialization/BundleYaml.cs rename to src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs index a0207407e..53fa62542 100644 --- a/src/services/Elastic.Changelog/Serialization/BundleYaml.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs @@ -4,22 +4,22 @@ using YamlDotNet.Serialization; -namespace Elastic.Changelog.Serialization; +namespace Elastic.Documentation.Configuration.ReleaseNotes; /// -/// Internal DTO for YAML serialization of bundled changelog data. +/// DTO for YAML serialization of bundled changelog data. /// Maps directly to the bundle YAML file structure. /// -internal record BundleYaml +public sealed record BundleDto { - public List? Products { get; set; } - public List? Entries { get; set; } + public List? Products { get; set; } + public List? Entries { get; set; } } /// -/// Internal DTO for bundled product info in YAML. +/// DTO for bundled product info in YAML. /// -internal record BundledProductYaml +public sealed record BundledProductDto { public string? Product { get; set; } public string? Target { get; set; } @@ -27,14 +27,14 @@ internal record BundledProductYaml } /// -/// Internal DTO for bundled entry in YAML. +/// DTO for bundled entry in YAML. /// -internal record BundledEntryYaml +public sealed record BundledEntryDto { - public BundledFileYaml? File { get; set; } + public BundledFileDto? File { get; set; } public string? Type { get; set; } public string? Title { get; set; } - public List? Products { get; set; } + public List? Products { get; set; } public string? Description { get; set; } public string? Impact { get; set; } public string? Action { get; set; } @@ -48,9 +48,9 @@ internal record BundledEntryYaml } /// -/// Internal DTO for bundled file info in YAML. +/// DTO for bundled file info in YAML. /// -internal record BundledFileYaml +public sealed record BundledFileDto { public string? Name { get; set; } public string? Checksum { get; set; } diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs new file mode 100644 index 000000000..4bb4b524e --- /dev/null +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs @@ -0,0 +1,195 @@ +// 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 System.IO.Abstractions; +using Elastic.Documentation.ReleaseNotes; +using YamlDotNet.Core; + +namespace Elastic.Documentation.Configuration.ReleaseNotes; + +/// +/// Service for loading, resolving, filtering, and merging changelog bundles. +/// +public class BundleLoader(IFileSystem fileSystem) +{ + /// + /// Loads all changelog bundles from a folder. + /// + /// The absolute path to the bundles folder. + /// Callback to emit warnings during loading. + /// A list of successfully loaded bundles. + public IReadOnlyList LoadBundles( + string bundlesFolderPath, + Action emitWarning) + { + var yamlFiles = fileSystem.Directory + .EnumerateFiles(bundlesFolderPath, "*.yaml") + .Concat(fileSystem.Directory.EnumerateFiles(bundlesFolderPath, "*.yml")) + .ToList(); + + var loadedBundles = new List(); + + foreach (var bundleFile in yamlFiles) + { + var bundleData = LoadBundle(bundleFile, emitWarning); + if (bundleData == null) + continue; + + var version = GetVersionFromBundle(bundleData) ?? fileSystem.Path.GetFileNameWithoutExtension(bundleFile); + var repo = bundleData.Products.Count > 0 + ? bundleData.Products[0].ProductId + : "elastic"; + + // Bundle directory is the directory containing the bundle file + var bundleDirectory = fileSystem.Path.GetDirectoryName(bundleFile) ?? bundlesFolderPath; + // Default changelog directory is parent of bundles folder + var changelogDirectory = fileSystem.Path.GetDirectoryName(bundleDirectory) ?? bundlesFolderPath; + + var entries = ResolveEntries(bundleData, changelogDirectory, emitWarning); + + loadedBundles.Add(new LoadedBundle(version, repo, bundleData, bundleFile, entries)); + } + + return loadedBundles; + } + + /// + /// Resolves entries from a bundle, loading from file references if needed. + /// + /// The parsed bundle data. + /// The changelog directory (parent of bundles folder). + /// Callback to emit warnings during resolution. + /// A list of resolved changelog entries. + public List ResolveEntries( + Bundle bundledData, + string changelogDirectory, + Action emitWarning) + { + var entries = new List(); + + foreach (var entry in bundledData.Entries) + { + ChangelogEntry? entryData = null; + + // If entry has resolved/inline data, use it directly + if (!string.IsNullOrWhiteSpace(entry.Title) && entry.Type != null) + entryData = ReleaseNotesSerialization.ConvertBundledEntry(entry); + else if (!string.IsNullOrWhiteSpace(entry.File?.Name)) + { + // Load from file reference - look in changelog directory (parent of bundles) + var filePath = fileSystem.Path.Combine(changelogDirectory, entry.File.Name); + + if (!fileSystem.File.Exists(filePath)) + { + emitWarning($"Referenced changelog file '{entry.File.Name}' not found at '{filePath}'."); + continue; + } + + try + { + var fileContent = fileSystem.File.ReadAllText(filePath); + var normalizedYaml = ReleaseNotesSerialization.NormalizeYaml(fileContent); + entryData = ReleaseNotesSerialization.DeserializeEntry(normalizedYaml); + } + catch (YamlException e) + { + emitWarning($"Failed to parse changelog file '{entry.File.Name}': {e.Message}"); + continue; + } + } + + if (entryData != null) + entries.Add(entryData); + } + + return entries; + } + + /// + /// Filters entries based on publish blocker configuration. + /// Uses PublishBlockerExtensions.ShouldBlock() for publish blocker filtering. + /// + /// The entries to filter. + /// Optional publish blocker configuration. + /// Filtered list of entries. + public IReadOnlyList FilterEntries( + IReadOnlyList entries, + PublishBlocker? publishBlocker) + { + if (publishBlocker is not { HasBlockingRules: true }) + return entries; + + return entries.Where(e => !publishBlocker.ShouldBlock(e)).ToList(); + } + + /// + /// Merges bundles that share the same target version/date into a single bundle. + /// + /// The sorted list of bundles to merge. + /// A list of bundles where same-target bundles are merged. + public IReadOnlyList MergeBundlesByTarget(IReadOnlyList bundles) + { + if (bundles.Count <= 1) + return bundles; + + return bundles + .GroupBy(b => b.Version) + .Select(MergeBundleGroup) + .OrderByDescending(b => VersionOrDate.Parse(b.Version)) + .ToList(); + } + + /// + /// Loads a single bundle from a file. + /// + private Bundle? LoadBundle(string filePath, Action emitWarning) + { + try + { + var bundleContent = fileSystem.File.ReadAllText(filePath); + return ReleaseNotesSerialization.DeserializeBundle(bundleContent); + } + catch (YamlException e) + { + var fileName = fileSystem.Path.GetFileName(filePath); + emitWarning($"Failed to parse changelog bundle '{fileName}': {e.Message}"); + return null; + } + } + + /// + /// Gets the version from a bundle's first product. + /// + private static string? GetVersionFromBundle(Bundle bundledData) => + bundledData.Products.Count > 0 ? bundledData.Products[0].Target : null; + + /// + /// Merges a group of bundles with the same target version into a single bundle. + /// + private static LoadedBundle MergeBundleGroup(IGrouping group) + { + var bundlesList = group.ToList(); + + if (bundlesList.Count == 1) + return bundlesList[0]; + + // Merge entries from all bundles + var mergedEntries = bundlesList.SelectMany(b => b.Entries).ToList(); + + // Combine repo names from all contributing bundles + var combinedRepo = string.Join("+", bundlesList.Select(b => b.Repo).Distinct().OrderBy(r => r)); + + // Use the first bundle's metadata as the base + var first = bundlesList[0]; + + return new LoadedBundle( + first.Version, + combinedRepo, + first.Data, + first.FilePath, + mergedEntries + ); + } + +} diff --git a/src/services/Elastic.Changelog/Serialization/ChangelogEntryYaml.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogEntry.cs similarity index 87% rename from src/services/Elastic.Changelog/Serialization/ChangelogEntryYaml.cs rename to src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogEntry.cs index eff785028..410dea606 100644 --- a/src/services/Elastic.Changelog/Serialization/ChangelogEntryYaml.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogEntry.cs @@ -4,20 +4,20 @@ using YamlDotNet.Serialization; -namespace Elastic.Changelog.Serialization; +namespace Elastic.Documentation.Configuration.ReleaseNotes; /// /// DTO for YAML deserialization of changelog entries. /// Maps directly to the YAML file structure. /// Used by bundling service for direct deserialization with error handling. /// -public record ChangelogEntryYaml +public record ChangelogEntryDto { public string? Pr { get; set; } public List? Issues { get; set; } public string? Type { get; set; } public string? Subtype { get; set; } - public List? Products { get; set; } + public List? Products { get; set; } public List? Areas { get; set; } public string? Title { get; set; } public string? Description { get; set; } @@ -32,7 +32,7 @@ public record ChangelogEntryYaml /// DTO for product info in YAML. /// Used by bundling service for direct deserialization with error handling. /// -public record ProductInfoYaml +public record ProductInfoDto { public string? Product { get; set; } public string? Target { get; set; } diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs new file mode 100644 index 000000000..7c1d1dc1e --- /dev/null +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs @@ -0,0 +1,390 @@ +// 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 System.IO.Abstractions; +using System.Text.RegularExpressions; +using Elastic.Documentation.Configuration.Serialization; +using Elastic.Documentation.ReleaseNotes; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Elastic.Documentation.Configuration.ReleaseNotes; + +/// +/// Centralized YAML serialization for release notes/changelog operations. +/// Provides static deserializers and serializers configured for different use cases. +/// +public static partial class ReleaseNotesSerialization +{ + /// + /// Regex to normalize "version:" to "target:" in changelog YAML files. + /// Used for backward compatibility with older changelog formats. + /// + [GeneratedRegex(@"(\s+)version:", RegexOptions.Multiline)] + public static partial Regex VersionToTargetRegex(); + + private static readonly IDeserializer YamlDeserializer = + new StaticDeserializerBuilder(new YamlStaticContext()) + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .Build(); + + /// + /// Used for loading minimal changelog configuration (publish blocker). + /// + private static readonly IDeserializer IgnoreUnmatchedDeserializer = + new StaticDeserializerBuilder(new YamlStaticContext()) + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + private static readonly ISerializer YamlSerializer = + new StaticSerializerBuilder(new YamlStaticContext()) + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections) + .WithQuotingNecessaryStrings() + .DisableAliases() + .Build(); + + /// + /// Gets the raw YAML deserializer for changelog entry DTOs. + /// Used by bundling service for direct deserialization with error handling. + /// + public static IDeserializer GetEntryDeserializer() => YamlDeserializer; + + /// + /// Deserializes a changelog entry YAML content to domain type. + /// + public static ChangelogEntry DeserializeEntry(string yaml) + { + var yamlDto = YamlDeserializer.Deserialize(yaml); + return ToEntry(yamlDto); + } + + /// + /// Converts a raw YAML DTO to domain type. + /// Used by bundling service that handles deserialization separately for error handling. + /// + public static ChangelogEntry ConvertEntry(ChangelogEntryDto dto) => ToEntry(dto); + + /// + /// Converts a BundledEntry to a ChangelogEntry. + /// Used when inline entry data is provided in bundles. + /// + public static ChangelogEntry ConvertBundledEntry(BundledEntry entry) => ToEntry(entry); + + /// + /// Deserializes bundled changelog data YAML content to domain type. + /// + public static Bundle DeserializeBundle(string yaml) + { + var yamlDto = YamlDeserializer.Deserialize(yaml); + return ToBundle(yamlDto); + } + + /// + /// Serializes a changelog entry to YAML. + /// + public static string SerializeEntry(ChangelogEntry entry) + { + var dto = ToDto(entry); + return YamlSerializer.Serialize(dto); + } + + /// + /// Serializes bundled changelog data to YAML. + /// + public static string SerializeBundle(Bundle bundle) + { + var dto = ToDto(bundle); + return YamlSerializer.Serialize(dto); + } + + #region Manual Mapping Methods + + private static ChangelogEntry ToEntry(ChangelogEntryDto dto) => new() + { + Pr = dto.Pr, + Issues = dto.Issues, + Type = ParseEntryType(dto.Type), + Subtype = ParseEntrySubtype(dto.Subtype), + Products = dto.Products?.Select(ToProductReference).ToList(), + Areas = dto.Areas, + Title = dto.Title ?? "", + Description = dto.Description, + Impact = dto.Impact, + Action = dto.Action, + FeatureId = dto.FeatureId, + Highlight = dto.Highlight + }; + + private static ChangelogEntry ToEntry(BundledEntry entry) => new() + { + Pr = entry.Pr, + Issues = entry.Issues, + Type = entry.Type ?? ChangelogEntryType.Invalid, + Subtype = entry.Subtype, + Products = entry.Products, + Areas = entry.Areas, + Title = entry.Title ?? "", + Description = entry.Description, + Impact = entry.Impact, + Action = entry.Action, + FeatureId = entry.FeatureId, + Highlight = entry.Highlight + }; + + private static ProductReference ToProductReference(ProductInfoDto dto) => new() + { + ProductId = dto.Product ?? "", + Target = dto.Target, + Lifecycle = ParseLifecycle(dto.Lifecycle) + }; + + private static Bundle ToBundle(BundleDto dto) => new() + { + Products = dto.Products?.Select(ToBundledProduct).ToList() ?? [], + Entries = dto.Entries?.Select(ToBundledEntry).ToList() ?? [] + }; + + private static BundledProduct ToBundledProduct(BundledProductDto dto) => new() + { + ProductId = dto.Product ?? "", + Target = dto.Target, + Lifecycle = ParseLifecycle(dto.Lifecycle) + }; + + private static BundledEntry ToBundledEntry(BundledEntryDto dto) => new() + { + File = dto.File != null ? ToBundledFile(dto.File) : null, + Type = ParseEntryTypeNullable(dto.Type), + Title = dto.Title, + Products = dto.Products?.Select(ToProductReference).ToList(), + Description = dto.Description, + Impact = dto.Impact, + Action = dto.Action, + FeatureId = dto.FeatureId, + Highlight = dto.Highlight, + Subtype = ParseEntrySubtype(dto.Subtype), + Areas = dto.Areas, + Pr = dto.Pr, + Issues = dto.Issues + }; + + private static BundledFile ToBundledFile(BundledFileDto dto) => new() + { + Name = dto.Name ?? "", + Checksum = dto.Checksum ?? "" + }; + + private static ChangelogEntryType ParseEntryType(string? value) + { + if (string.IsNullOrEmpty(value)) + return ChangelogEntryType.Invalid; + + return ChangelogEntryTypeExtensions.TryParse(value, out var result, ignoreCase: true, allowMatchingMetadataAttribute: true) + ? result + : ChangelogEntryType.Invalid; + } + + private static ChangelogEntryType? ParseEntryTypeNullable(string? value) + { + if (string.IsNullOrEmpty(value)) + return null; + + return ChangelogEntryTypeExtensions.TryParse(value, out var result, ignoreCase: true, allowMatchingMetadataAttribute: true) + ? result + : null; + } + + private static ChangelogEntrySubtype? ParseEntrySubtype(string? value) + { + if (string.IsNullOrEmpty(value)) + return null; + + return ChangelogEntrySubtypeExtensions.TryParse(value, out var result, ignoreCase: true, allowMatchingMetadataAttribute: true) + ? result + : null; + } + + private static Lifecycle? ParseLifecycle(string? value) + { + if (string.IsNullOrEmpty(value)) + return null; + + return LifecycleExtensions.TryParse(value, out var result, ignoreCase: true, allowMatchingMetadataAttribute: true) + ? result + : null; + } + + // Reverse mappings (Domain → DTO) for serialization + + private static ChangelogEntryDto ToDto(ChangelogEntry entry) => new() + { + Pr = entry.Pr, + Issues = entry.Issues?.ToList(), + Type = EntryTypeToString(entry.Type), + Subtype = EntrySubtypeToString(entry.Subtype), + Products = entry.Products?.Select(ToDto).ToList(), + Areas = entry.Areas?.ToList(), + Title = entry.Title, + Description = entry.Description, + Impact = entry.Impact, + Action = entry.Action, + FeatureId = entry.FeatureId, + Highlight = entry.Highlight + }; + + private static ProductInfoDto ToDto(ProductReference product) => new() + { + Product = product.ProductId, + Target = product.Target, + Lifecycle = LifecycleToString(product.Lifecycle) + }; + + private static BundleDto ToDto(Bundle bundle) => new() + { + Products = bundle.Products.Select(ToDto).ToList(), + Entries = bundle.Entries.Select(ToDto).ToList() + }; + + private static BundledProductDto ToDto(BundledProduct product) => new() + { + Product = product.ProductId, + Target = product.Target, + Lifecycle = LifecycleToString(product.Lifecycle) + }; + + private static BundledEntryDto ToDto(BundledEntry entry) => new() + { + File = entry.File != null ? ToDto(entry.File) : null, + Type = EntryTypeNullableToString(entry.Type), + Title = entry.Title, + Products = entry.Products?.Select(ToDto).ToList(), + Description = entry.Description, + Impact = entry.Impact, + Action = entry.Action, + FeatureId = entry.FeatureId, + Highlight = entry.Highlight, + Subtype = EntrySubtypeToString(entry.Subtype), + Areas = entry.Areas?.ToList(), + Pr = entry.Pr, + Issues = entry.Issues?.ToList() + }; + + private static BundledFileDto ToDto(BundledFile file) => new() + { + Name = file.Name, + Checksum = file.Checksum + }; + + // Reverse enum conversion helpers + + private static string? EntryTypeToString(ChangelogEntryType value) => + value != ChangelogEntryType.Invalid ? value.ToStringFast(true) : null; + + private static string? EntryTypeNullableToString(ChangelogEntryType? value) => + value?.ToStringFast(true); + + private static string? EntrySubtypeToString(ChangelogEntrySubtype? value) => + value?.ToStringFast(true); + + private static string? LifecycleToString(Lifecycle? value) => + value?.ToStringFast(true); + + #endregion + + /// + /// Normalizes a YAML string by converting "version:" fields to "target:" for backward compatibility. + /// Also strips comment lines. + /// + /// The raw YAML content. + /// The normalized YAML content. + public static string NormalizeYaml(string yaml) + { + // Skip comment lines + var yamlLines = yaml.Split('\n'); + var yamlWithoutComments = string.Join('\n', yamlLines.Where(line => !line.TrimStart().StartsWith('#'))); + + // Normalize version to target + return VersionToTargetRegex().Replace(yamlWithoutComments, "$1target:"); + } + + /// + /// Loads the publish blocker configuration from a changelog.yml file. + /// Uses AOT-compatible deserialization via YamlStaticContext. + /// + /// The file system to read from. + /// The path to the changelog.yml configuration file. + /// The publish blocker configuration, or null if not found or not configured. + public static PublishBlocker? LoadPublishBlocker(IFileSystem fileSystem, string configPath) + { + if (!fileSystem.File.Exists(configPath)) + return null; + + var yamlContent = fileSystem.File.ReadAllText(configPath); + if (string.IsNullOrWhiteSpace(yamlContent)) + return null; + + var yamlConfig = IgnoreUnmatchedDeserializer.Deserialize(yamlContent); + if (yamlConfig?.Block?.Publish is null) + return null; + + return ParsePublishBlocker(yamlConfig.Block.Publish); + } + + /// + /// Parses a PublishBlockerMinimalDto into a PublishBlocker domain type. + /// + private static PublishBlocker? ParsePublishBlocker(PublishBlockerMinimalDto? dto) + { + if (dto == null) + return null; + + var types = dto.Types?.Count > 0 ? dto.Types.ToList() : null; + var areas = dto.Areas?.Count > 0 ? dto.Areas.ToList() : null; + + if (types == null && areas == null) + return null; + + return new PublishBlocker + { + Types = types, + Areas = areas + }; + } +} + +/// +/// Minimal DTO for changelog configuration - only includes block configuration. +/// Used for AOT-compatible lightweight loading of publish blocker configuration. +/// Registered with YamlStaticContext for source-generated deserialization. +/// +public sealed class ChangelogConfigMinimalDto +{ + /// Block configuration section. + public BlockConfigMinimalDto? Block { get; set; } +} + +/// +/// Minimal DTO for block configuration. +/// Registered with YamlStaticContext for source-generated deserialization. +/// +public sealed class BlockConfigMinimalDto +{ + /// Publish blocker configuration. + public PublishBlockerMinimalDto? Publish { get; set; } +} + +/// +/// Minimal DTO for publish blocker configuration. +/// Registered with YamlStaticContext for source-generated deserialization. +/// +public sealed class PublishBlockerMinimalDto +{ + /// Entry types to block from publishing. + public List? Types { get; set; } + + /// Entry areas to block from publishing. + public List? Areas { get; set; } +} diff --git a/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs b/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs index 0655c37e2..5e6e6a2fc 100644 --- a/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs +++ b/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs @@ -5,6 +5,7 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Products; +using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Configuration.Search; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; @@ -13,25 +14,41 @@ namespace Elastic.Documentation.Configuration.Serialization; [YamlStaticContext] +// Assembly configuration [YamlSerializable(typeof(AssemblyConfiguration))] [YamlSerializable(typeof(Repository))] [YamlSerializable(typeof(NarrativeRepository))] [YamlSerializable(typeof(PublishEnvironment))] [YamlSerializable(typeof(GoogleTagManager))] [YamlSerializable(typeof(ContentSource))] +// Versions configuration [YamlSerializable(typeof(VersionsConfigDto))] [YamlSerializable(typeof(ProductConfigDto))] [YamlSerializable(typeof(VersioningSystemDto))] [YamlSerializable(typeof(ProductDto))] +// Legacy URL mappings [YamlSerializable(typeof(LegacyUrlMappingDto))] [YamlSerializable(typeof(LegacyUrlMappingConfigDto))] +// Table of contents [YamlSerializable(typeof(DocumentationSetFile))] [YamlSerializable(typeof(TableOfContentsFile))] [YamlSerializable(typeof(SiteNavigationFile))] [YamlSerializable(typeof(PhantomRegistration))] [YamlSerializable(typeof(ProductLink))] +// Search configuration [YamlSerializable(typeof(SearchConfigDto))] [YamlSerializable(typeof(QueryRuleDto))] [YamlSerializable(typeof(QueryRuleCriteriaDto))] [YamlSerializable(typeof(QueryRuleActionsDto))] +// Release notes / changelog YAML DTOs +[YamlSerializable(typeof(ChangelogEntryDto))] +[YamlSerializable(typeof(ProductInfoDto))] +[YamlSerializable(typeof(BundleDto))] +[YamlSerializable(typeof(BundledProductDto))] +[YamlSerializable(typeof(BundledEntryDto))] +[YamlSerializable(typeof(BundledFileDto))] +// Changelog configuration minimal DTOs +[YamlSerializable(typeof(ChangelogConfigMinimalDto))] +[YamlSerializable(typeof(BlockConfigMinimalDto))] +[YamlSerializable(typeof(PublishBlockerMinimalDto))] public partial class YamlStaticContext; diff --git a/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs b/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs index a81b27f83..d55f2ef25 100644 --- a/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs +++ b/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs @@ -143,4 +143,45 @@ public static bool HasParent(this IDirectoryInfo directory, string parentName, S return null; } + + /// + /// Resolves a path relative to this directory, handling three cases: + /// + /// Directory-relative paths (starting with '/') - resolved relative to this directory after trimming the leading slash + /// Absolute filesystem paths (e.g., C:\path on Windows) - returned as-is + /// Relative paths - resolved relative to this directory + /// + /// + /// + /// + /// This method prevents the issue where Path.Combine silently drops the base directory + /// when passed an absolute path as the second argument. + /// + /// + /// Input paths from markdown always use forward slashes ('/'), but the returned path is normalized + /// to use the OS-appropriate directory separator for file system access. + /// + /// + /// The base directory to resolve from + /// The path to resolve (typically from markdown, using '/' separators) + /// The resolved absolute path with OS-appropriate separators + public static string ResolvePathFrom(this IDirectoryInfo directory, string relativePath) + { + var fsPath = directory.FileSystem.Path; + + var normalizedPath = relativePath.Replace('/', fsPath.DirectorySeparatorChar); + + // Handle directory-relative paths (convention: paths starting with '/') + // This convention means "relative to this directory" rather than an absolute filesystem path + if (relativePath.StartsWith('/')) + return fsPath.Combine(directory.FullName, normalizedPath.TrimStart(fsPath.DirectorySeparatorChar)); + + // Handle absolute filesystem paths - use directly + // This prevents Path.Combine from silently dropping the base directory + if (fsPath.IsPathRooted(normalizedPath)) + return normalizedPath; + + // Handle relative paths + return fsPath.Combine(directory.FullName, normalizedPath); + } } diff --git a/src/Elastic.Documentation/ReleaseNotes/Bundle.cs b/src/Elastic.Documentation/ReleaseNotes/Bundle.cs new file mode 100644 index 000000000..aacccb9e4 --- /dev/null +++ b/src/Elastic.Documentation/ReleaseNotes/Bundle.cs @@ -0,0 +1,18 @@ +// 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 + +namespace Elastic.Documentation.ReleaseNotes; + +/// +/// Domain type representing bundled changelog data. +/// Contains products and entries for a changelog bundle. +/// +public record Bundle +{ + /// Products included in this bundle. + public IReadOnlyList Products { get; init; } = []; + + /// Changelog entries in this bundle. + public IReadOnlyList Entries { get; init; } = []; +} diff --git a/src/services/Elastic.Changelog/Bundle.cs b/src/Elastic.Documentation/ReleaseNotes/BundledEntry.cs similarity index 52% rename from src/services/Elastic.Changelog/Bundle.cs rename to src/Elastic.Documentation/ReleaseNotes/BundledEntry.cs index 0c8ba2899..16630e670 100644 --- a/src/services/Elastic.Changelog/Bundle.cs +++ b/src/Elastic.Documentation/ReleaseNotes/BundledEntry.cs @@ -2,54 +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 System.Diagnostics.CodeAnalysis; -using Elastic.Documentation; - -namespace Elastic.Changelog; - -/// -/// Domain type representing bundled changelog data. -/// Contains products and entries for a changelog bundle. -/// -public record Bundle -{ - /// Products included in this bundle. - public IReadOnlyList Products { get; init; } = []; - - /// Changelog entries in this bundle. - public IReadOnlyList Entries { get; init; } = []; -} - -/// -/// Product included in a bundle with strongly typed lifecycle. -/// -public record BundledProduct -{ - /// - /// Parameterless constructor for object initializer syntax. - /// - public BundledProduct() { } - - /// - /// Constructor with all parameters. - /// - [SetsRequiredMembers] - public BundledProduct(string productId, string? target = null, Lifecycle? lifecycle = null) - { - ProductId = productId; - Target = target; - Lifecycle = lifecycle; - } - - /// The product identifier. - public required string ProductId { get; init; } - - /// Optional target version. - public string? Target { get; init; } - - /// The lifecycle stage of the feature for this product. - public Lifecycle? Lifecycle { get; init; } -} +namespace Elastic.Documentation.ReleaseNotes; /// /// A changelog entry within a bundle, with strongly typed enums. @@ -95,15 +48,3 @@ public record BundledEntry /// Related issue URLs or references. public IReadOnlyList? Issues { get; init; } } - -/// -/// File information in a bundled changelog entry. -/// -public record BundledFile -{ - /// The filename. - public required string Name { get; init; } - - /// The file checksum. - public required string Checksum { get; init; } -} diff --git a/src/Elastic.Documentation/ReleaseNotes/BundledFile.cs b/src/Elastic.Documentation/ReleaseNotes/BundledFile.cs new file mode 100644 index 000000000..2dda13673 --- /dev/null +++ b/src/Elastic.Documentation/ReleaseNotes/BundledFile.cs @@ -0,0 +1,17 @@ +// 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 + +namespace Elastic.Documentation.ReleaseNotes; + +/// +/// File information in a bundled changelog entry. +/// +public record BundledFile +{ + /// The filename. + public required string Name { get; init; } + + /// The file checksum. + public required string Checksum { get; init; } +} diff --git a/src/Elastic.Documentation/ReleaseNotes/BundledProduct.cs b/src/Elastic.Documentation/ReleaseNotes/BundledProduct.cs new file mode 100644 index 000000000..9598cad84 --- /dev/null +++ b/src/Elastic.Documentation/ReleaseNotes/BundledProduct.cs @@ -0,0 +1,38 @@ +// 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 System.Diagnostics.CodeAnalysis; + +namespace Elastic.Documentation.ReleaseNotes; + +/// +/// Product included in a bundle with strongly typed lifecycle. +/// +public record BundledProduct +{ + /// + /// Parameterless constructor for object initializer syntax. + /// + public BundledProduct() { } + + /// + /// Constructor with all parameters. + /// + [SetsRequiredMembers] + public BundledProduct(string productId, string? target = null, Lifecycle? lifecycle = null) + { + ProductId = productId; + Target = target; + Lifecycle = lifecycle; + } + + /// The product identifier. + public required string ProductId { get; init; } + + /// Optional target version. + public string? Target { get; init; } + + /// The lifecycle stage of the feature for this product. + public Lifecycle? Lifecycle { get; init; } +} diff --git a/src/services/Elastic.Changelog/ChangelogEntry.cs b/src/Elastic.Documentation/ReleaseNotes/ChangelogEntry.cs similarity index 74% rename from src/services/Elastic.Changelog/ChangelogEntry.cs rename to src/Elastic.Documentation/ReleaseNotes/ChangelogEntry.cs index 594374b3c..47e40dd8b 100644 --- a/src/services/Elastic.Changelog/ChangelogEntry.cs +++ b/src/Elastic.Documentation/ReleaseNotes/ChangelogEntry.cs @@ -2,9 +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.Documentation; - -namespace Elastic.Changelog; +namespace Elastic.Documentation.ReleaseNotes; /// /// Domain type representing a changelog entry. @@ -47,19 +45,25 @@ public record ChangelogEntry /// Whether this entry should be highlighted. public bool? Highlight { get; init; } -} - -/// -/// Product reference with strongly typed lifecycle. -/// -public record ProductReference -{ - /// The product identifier. - public required string ProductId { get; init; } - - /// Optional target version. - public string? Target { get; init; } - /// The lifecycle stage of the feature for this product. - public Lifecycle? Lifecycle { get; init; } + /// + /// Converts this ChangelogEntry to a BundledEntry for embedding in bundles. + /// File property is set to null; set it separately using a 'with' expression. + /// + public BundledEntry ToBundledEntry() => new() + { + File = null, + Type = Type != ChangelogEntryType.Invalid ? Type : null, + Title = Title, + Products = Products, + Description = Description, + Impact = Impact, + Action = Action, + FeatureId = FeatureId, + Highlight = Highlight, + Subtype = Subtype, + Areas = Areas, + Pr = Pr, + Issues = Issues + }; } diff --git a/src/services/Elastic.Changelog/ChangelogTextUtilities.cs b/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs similarity index 94% rename from src/services/Elastic.Changelog/ChangelogTextUtilities.cs rename to src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs index 865846a72..e6e84bea7 100644 --- a/src/services/Elastic.Changelog/ChangelogTextUtilities.cs +++ b/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs @@ -3,12 +3,11 @@ // See the LICENSE file in the project root for more information using System.Text.RegularExpressions; -using Elastic.Documentation; -namespace Elastic.Changelog; +namespace Elastic.Documentation.ReleaseNotes; /// -/// Static utility methods for text processing in changelog generation +/// Static utility methods for text processing in changelog generation. /// public static partial class ChangelogTextUtilities { @@ -16,7 +15,7 @@ public static partial class ChangelogTextUtilities private static partial Regex TrailingNumberRegex(); /// - /// Capitalizes first letter and ensures text ends with period + /// Capitalizes first letter and ensures text ends with period. /// public static string Beautify(string text) { @@ -33,7 +32,7 @@ public static string Beautify(string text) } /// - /// Indents each line with two spaces + /// Indents each line with two spaces. /// public static string Indent(string text) { @@ -42,7 +41,7 @@ public static string Indent(string text) } /// - /// Converts title to slug format for folder names and anchors (lowercase, dashes instead of spaces) + /// Converts title to slug format for folder names and anchors (lowercase, dashes instead of spaces). /// public static string TitleToSlug(string title) { @@ -53,7 +52,7 @@ public static string TitleToSlug(string title) } /// - /// Formats area header - capitalizes first letter and replaces hyphens with spaces + /// Formats area header - capitalizes first letter and replaces hyphens with spaces. /// public static string FormatAreaHeader(string area) { @@ -67,7 +66,7 @@ public static string FormatAreaHeader(string area) } /// - /// Formats subtype header - capitalizes first letter and replaces hyphens with spaces + /// Formats subtype header - capitalizes first letter and replaces hyphens with spaces. /// public static string FormatSubtypeHeader(string subtype) { @@ -81,7 +80,7 @@ public static string FormatSubtypeHeader(string subtype) } /// - /// Sanitizes filename by converting to lowercase, replacing special characters with dashes, and limiting length + /// Sanitizes filename by converting to lowercase, replacing special characters with dashes, and limiting length. /// public static string SanitizeFilename(string input) { @@ -133,7 +132,7 @@ public static string StripSquareBracketPrefix(string title) } /// - /// Extracts PR number from PR URL or reference + /// Extracts PR number from PR URL or reference. /// public static int? ExtractPrNumber(string prUrl, string? defaultOwner = null, string? defaultRepo = null) { @@ -168,7 +167,7 @@ public static string StripSquareBracketPrefix(string title) } /// - /// Formats PR link as markdown + /// Formats PR link as markdown. /// public static string FormatPrLink(string pr, string repo, bool hidePrivateLinks) { @@ -194,7 +193,7 @@ public static string FormatPrLink(string pr, string repo, bool hidePrivateLinks) } /// - /// Formats issue link as markdown + /// Formats issue link as markdown. /// public static string FormatIssueLink(string issue, string repo, bool hidePrivateLinks) { @@ -220,7 +219,7 @@ public static string FormatIssueLink(string issue, string repo, bool hidePrivate } /// - /// Formats PR link as asciidoc + /// Formats PR link as asciidoc. /// public static string FormatPrLinkAsciidoc(string pr, string repo, bool hidePrivateLinks) { @@ -241,7 +240,7 @@ public static string FormatPrLinkAsciidoc(string pr, string repo, bool hidePriva } /// - /// Formats issue link as asciidoc + /// Formats issue link as asciidoc. /// public static string FormatIssueLinkAsciidoc(string issue, string repo, bool hidePrivateLinks) { @@ -262,7 +261,7 @@ public static string FormatIssueLinkAsciidoc(string issue, string repo, bool hid } /// - /// Converts repo name to attribute format for asciidoc links + /// Converts repo name to attribute format for asciidoc links. /// private static string ConvertRepoToAttributeName(string repo, string suffix) { @@ -363,9 +362,6 @@ public static string GenerateSlug(string title, int maxWords = 6) .Where(word => !string.IsNullOrEmpty(word)) .ToArray(); - if (words.Length == 0) - return "untitled"; - - return string.Join("-", words); + return words.Length == 0 ? "untitled" : string.Join("-", words); } } diff --git a/src/Elastic.Documentation/ReleaseNotes/LoadedBundle.cs b/src/Elastic.Documentation/ReleaseNotes/LoadedBundle.cs new file mode 100644 index 000000000..2a7c4edb3 --- /dev/null +++ b/src/Elastic.Documentation/ReleaseNotes/LoadedBundle.cs @@ -0,0 +1,29 @@ +// 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 + +namespace Elastic.Documentation.ReleaseNotes; + +/// +/// Represents a loaded and parsed changelog bundle with its metadata. +/// +/// The semantic version or date extracted from the bundle. +/// The repository/product name. +/// The full parsed bundle data. +/// The absolute path to the bundle file. +/// Resolved changelog entries (from inline data or file references). +public record LoadedBundle( + string Version, + string Repo, + Bundle Data, + string FilePath, + IReadOnlyList Entries) +{ + /// + /// Entries grouped by their changelog entry type. + /// + public IReadOnlyDictionary> EntriesByType => + Entries + .GroupBy(e => e.Type) + .ToDictionary(g => g.Key, g => (IReadOnlyCollection)g.ToList().AsReadOnly()); +} diff --git a/src/Elastic.Documentation/ReleaseNotes/ProductReference.cs b/src/Elastic.Documentation/ReleaseNotes/ProductReference.cs new file mode 100644 index 000000000..37ba02dfa --- /dev/null +++ b/src/Elastic.Documentation/ReleaseNotes/ProductReference.cs @@ -0,0 +1,20 @@ +// 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 + +namespace Elastic.Documentation.ReleaseNotes; + +/// +/// Product reference with strongly typed lifecycle. +/// +public record ProductReference +{ + /// The product identifier. + public required string ProductId { get; init; } + + /// Optional target version. + public string? Target { get; init; } + + /// The lifecycle stage of the feature for this product. + public Lifecycle? Lifecycle { get; init; } +} diff --git a/src/Elastic.Documentation/ReleaseNotes/PublishBlocker.cs b/src/Elastic.Documentation/ReleaseNotes/PublishBlocker.cs new file mode 100644 index 000000000..dd4966275 --- /dev/null +++ b/src/Elastic.Documentation/ReleaseNotes/PublishBlocker.cs @@ -0,0 +1,26 @@ +// 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 + +namespace Elastic.Documentation.ReleaseNotes; + +/// +/// Configuration for blocking changelog entries from publishing based on type or area. +/// +public record PublishBlocker +{ + /// + /// Entry types to block from publishing (e.g., "deprecation", "known-issue"). + /// + public IReadOnlyList? Types { get; init; } + + /// + /// Entry areas to block from publishing (e.g., "Internal", "Experimental"). + /// + public IReadOnlyList? Areas { get; init; } + + /// + /// Returns true if this blocker has any blocking rules configured. + /// + public bool HasBlockingRules => (Types?.Count > 0) || (Areas?.Count > 0); +} diff --git a/src/services/Elastic.Changelog/Configuration/PublishBlockerExtensions.cs b/src/Elastic.Documentation/ReleaseNotes/PublishBlockerExtensions.cs similarity index 69% rename from src/services/Elastic.Changelog/Configuration/PublishBlockerExtensions.cs rename to src/Elastic.Documentation/ReleaseNotes/PublishBlockerExtensions.cs index 305775e7a..0afc50195 100644 --- a/src/services/Elastic.Changelog/Configuration/PublishBlockerExtensions.cs +++ b/src/Elastic.Documentation/ReleaseNotes/PublishBlockerExtensions.cs @@ -2,18 +2,15 @@ // 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.Documentation; -using Elastic.Documentation.Configuration.Changelog; - -namespace Elastic.Changelog.Configuration; +namespace Elastic.Documentation.ReleaseNotes; /// -/// Extension methods for PublishBlocker that depend on ChangelogEntry +/// Extension methods for PublishBlocker that depend on ChangelogEntry. /// public static class PublishBlockerExtensions { /// - /// Checks if a changelog entry should be blocked from publishing + /// Checks if a changelog entry should be blocked from publishing. /// public static bool ShouldBlock(this PublishBlocker blocker, ChangelogEntry entry) { @@ -26,13 +23,8 @@ public static bool ShouldBlock(this PublishBlocker blocker, ChangelogEntry entry } // Check if any of the entry's areas are blocked - if (blocker.Areas?.Count > 0 - && entry.Areas?.Count > 0 - && entry.Areas.Any(area => blocker.Areas.Any(blocked => blocked.Equals(area, StringComparison.OrdinalIgnoreCase)))) - { - return true; - } - - return false; + return blocker.Areas?.Count > 0 + && entry.Areas?.Count > 0 + && entry.Areas.Any(area => blocker.Areas.Any(blocked => blocked.Equals(area, StringComparison.OrdinalIgnoreCase))); } } diff --git a/src/Elastic.Documentation/VersionOrDate.cs b/src/Elastic.Documentation/VersionOrDate.cs new file mode 100644 index 000000000..4d1473028 --- /dev/null +++ b/src/Elastic.Documentation/VersionOrDate.cs @@ -0,0 +1,92 @@ +// 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 System.Globalization; + +namespace Elastic.Documentation; + +/// +/// A version representation that can be either a semantic version or a date. +/// This enables proper sorting of mixed version formats used in different release strategies. +/// +/// +/// Comparison rules: +/// - Semver versions sort among themselves using semver rules +/// - Dates sort among themselves chronologically +/// - When mixing types: semver versions come before dates (typical for products transitioning versioning schemes) +/// - Raw strings (fallback) sort lexicographically and come after both semver and dates +/// +/// The semantic version, if parsed successfully. +/// The date, if parsed successfully from ISO 8601 format. +/// The raw string for fallback comparison. +public record VersionOrDate(SemVersion? SemVer, DateOnly? Date, string? Raw) : IComparable +{ + /// + /// Parses a version string that can be either semver (e.g., "9.3.0") or a date (e.g., "2025-08-05"). + /// Falls back to raw string comparison for non-standard formats. + /// + /// The version string to parse. + /// A instance representing the parsed version. + public static VersionOrDate Parse(string version) + { + // Try semver first + if (SemVersion.TryParse(version, out var semVersion)) + return new VersionOrDate(semVersion, null, null); + + // Try date parsing (ISO 8601 YYYY-MM-DD format) + if (DateOnly.TryParseExact(version, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + return new VersionOrDate(null, date, null); + + // Fallback - treat as raw string for lexicographic sorting + return new VersionOrDate(null, null, version); + } + + /// + public int CompareTo(VersionOrDate? other) + { + if (other is null) + return 1; + + // Both are semver + if (SemVer is not null && other.SemVer is not null) + return SemVer.CompareTo(other.SemVer); + + // Both are dates + if (Date.HasValue && other.Date.HasValue) + return Date.Value.CompareTo(other.Date.Value); + + // Semver vs Date: semver comes first (returns positive when comparing semver to date for descending sort) + if (SemVer is not null && other.Date.HasValue) + return 1; + if (Date.HasValue && other.SemVer is not null) + return -1; + + // Semver vs Raw: semver comes first + if (SemVer is not null && other.Raw is not null) + return 1; + if (Raw is not null && other.SemVer is not null) + return -1; + + // Date vs Raw: date comes first + if (Date.HasValue && other.Raw is not null) + return 1; + if (Raw is not null && other.Date.HasValue) + return -1; + + // Both are raw strings + return string.Compare(Raw, other.Raw, StringComparison.Ordinal); + } + + /// Compares two values. + public static bool operator <(VersionOrDate left, VersionOrDate right) => left.CompareTo(right) < 0; + + /// Compares two values. + public static bool operator <=(VersionOrDate left, VersionOrDate right) => left.CompareTo(right) <= 0; + + /// Compares two values. + public static bool operator >(VersionOrDate left, VersionOrDate right) => left.CompareTo(right) > 0; + + /// Compares two values. + public static bool operator >=(VersionOrDate left, VersionOrDate right) => left.CompareTo(right) >= 0; +} diff --git a/src/Elastic.Markdown/Elastic.Markdown.csproj b/src/Elastic.Markdown/Elastic.Markdown.csproj index 19c1cbf85..058ffc171 100644 --- a/src/Elastic.Markdown/Elastic.Markdown.csproj +++ b/src/Elastic.Markdown/Elastic.Markdown.csproj @@ -38,7 +38,6 @@ - diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index 0b515106c..9953c6a8f 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -11,6 +11,7 @@ using Elastic.Markdown.Helpers; using Elastic.Markdown.Myst; using Elastic.Markdown.Myst.Directives; +using Elastic.Markdown.Myst.Directives.Changelog; using Elastic.Markdown.Myst.Directives.Include; using Elastic.Markdown.Myst.Directives.Stepper; using Elastic.Markdown.Myst.FrontMatter; @@ -281,8 +282,16 @@ public static List GetAnchors( }; }); + // Collect headings from Changelog directives + var changelogTocs = document + .Descendants() + .OfType() + .SelectMany(changelog => changelog.GeneratedTableOfContent + .Select(tocItem => new { TocItem = tocItem, changelog.Line })); + var toc = headingTocs .Concat(stepperTocs) + .Concat(changelogTocs) .Concat(includedTocs) .OrderBy(item => item.Line) .Select(item => item.TocItem) diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index 6d799ba1f..8e5de07d9 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs @@ -2,42 +2,51 @@ // 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 System.Text.RegularExpressions; -using Elastic.Changelog; -using Elastic.Changelog.Serialization; using Elastic.Documentation; +using Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.Configuration.ReleaseNotes; +using Elastic.Documentation.Extensions; +using Elastic.Documentation.ReleaseNotes; using Elastic.Markdown.Diagnostics; -using YamlDotNet.Core; namespace Elastic.Markdown.Myst.Directives.Changelog; /// -/// Represents a loaded and parsed changelog bundle with its metadata. +/// Specifies which changelog entry types to display in the directive output. /// -/// The semantic version extracted from the bundle. -/// The repository/product name. -/// The full parsed bundle data. -/// The absolute path to the bundle file. -/// Resolved changelog entries (from inline data or file references). -public record LoadedBundle( - string Version, - string Repo, - Bundle Data, - string FilePath, - IReadOnlyList Entries) +public enum ChangelogTypeFilter { /// - /// Entries grouped by their changelog entry type. + /// Default behavior: show all types EXCEPT known issues, breaking changes, and deprecations. + /// These "separated types" are typically shown on their own dedicated pages. /// - public IReadOnlyDictionary> EntriesByType => - Entries - .GroupBy(e => e.Type) - .ToDictionary(g => g.Key, g => (IReadOnlyCollection)g.ToList().AsReadOnly()); + Default, + + /// + /// Show all entry types including known issues, breaking changes, and deprecations. + /// + All, + + /// + /// Show only breaking change entries. + /// + BreakingChange, + + /// + /// Show only deprecation entries. + /// + Deprecation, + + /// + /// Show only known issue entries. + /// + KnownIssue } /// /// A directive block that reads all changelog bundles from a folder and renders them inline, -/// ordered by version number (semver, descending). +/// ordered by version (descending). Supports both semver (e.g., "9.3.0") and date-based +/// versions (e.g., "2025-08-05") for Serverless and similar release strategies. /// /// /// Usage: @@ -54,17 +63,13 @@ public record LoadedBundle( /// /// Default bundles folder is changelog/bundles/ relative to the docset root. /// -public partial class ChangelogBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) +public class ChangelogBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { /// /// Default folder for changelog bundles, relative to the documentation source directory. /// private const string DefaultBundlesFolder = "changelog/bundles"; - // Regex to normalize "version:" to "target:" in changelog YAML files - [GeneratedRegex(@"(\s+)version:", RegexOptions.Multiline)] - private static partial Regex VersionToTargetRegex(); - public override string Directive => "changelog"; public ParserContext Context { get; } = context; @@ -89,18 +94,97 @@ public partial class ChangelogBlock(DirectiveBlockParser parser, ParserContext c /// public IReadOnlyList LoadedBundles { get; private set; } = []; + /// + /// Whether to group entries by area/component within each section. + /// Defaults to false in order to match CLI behavior. + /// + public bool Subsections { get; private set; } + + /// + /// Explicit path to the changelog configuration file, parsed from the :config: option. + /// If not specified, auto-discovers from docs/changelog.yml or changelog.yml relative to docset root. + /// + public string? ConfigPath { get; private set; } + + /// + /// The loaded publish blocker configuration used to filter entries. + /// If null, no publish filtering is applied. + /// + public PublishBlocker? PublishBlocker { get; private set; } + + /// + /// The type filter to apply when rendering changelog entries. + /// Default behavior excludes known issues, breaking changes, and deprecations. + /// + public ChangelogTypeFilter TypeFilter { get; private set; } + + /// + /// The entry types that are considered "separated" and excluded by default. + /// These types are typically shown on their own dedicated pages (e.g., known issues page, breaking changes page). + /// + public static readonly HashSet SeparatedTypes = + [ + ChangelogEntryType.KnownIssue, + ChangelogEntryType.BreakingChange, + ChangelogEntryType.Deprecation + ]; + + /// + /// Repository names that are marked as private in assembler.yml. + /// Links to these repositories will be hidden (commented out) in the rendered output. + /// Auto-detected from assembler configuration when available. + /// + public HashSet PrivateRepositories { get; private set; } = new(StringComparer.OrdinalIgnoreCase); + /// /// Returns all anchors that will be generated by this directive during rendering. /// public override IEnumerable GeneratedAnchors => ComputeGeneratedAnchors(); + /// + /// Returns table of contents items for the right-hand navigation. + /// + public IEnumerable GeneratedTableOfContent => ComputeTableOfContent(); + public override void FinalizeAndValidate(ParserContext context) { ExtractBundlesFolderPath(); + Subsections = PropBool("subsections"); + ConfigPath = Prop("config"); + TypeFilter = ParseTypeFilter(); + LoadConfiguration(); + LoadPrivateRepositories(); if (Found) LoadAndCacheBundles(); } + /// + /// Parses and validates the :type: option. + /// Valid values: all, breaking-change, deprecation, known-issue. + /// If not specified, returns Default (excludes separated types). + /// + private ChangelogTypeFilter ParseTypeFilter() + { + var typeValue = Prop("type"); + if (string.IsNullOrWhiteSpace(typeValue)) + return ChangelogTypeFilter.Default; + + return typeValue.ToLowerInvariant() switch + { + "all" => ChangelogTypeFilter.All, + "breaking-change" => ChangelogTypeFilter.BreakingChange, + "deprecation" => ChangelogTypeFilter.Deprecation, + "known-issue" => ChangelogTypeFilter.KnownIssue, + _ => EmitInvalidTypeFilterWarning(typeValue) + }; + } + + private ChangelogTypeFilter EmitInvalidTypeFilterWarning(string typeValue) + { + this.EmitWarning($"Invalid :type: value '{typeValue}'. Valid values are: all, breaking-change, deprecation, known-issue. Using default behavior."); + return ChangelogTypeFilter.Default; + } + private void ExtractBundlesFolderPath() { var folderPath = Arguments; @@ -108,11 +192,7 @@ private void ExtractBundlesFolderPath() if (string.IsNullOrWhiteSpace(folderPath)) folderPath = DefaultBundlesFolder; - var resolveFrom = Build.DocumentationSourceDirectory.FullName; - if (folderPath.StartsWith('/')) - folderPath = folderPath.TrimStart('/'); - - BundlesFolderPath = Path.Combine(resolveFrom, folderPath); + BundlesFolderPath = Build.DocumentationSourceDirectory.ResolvePathFrom(folderPath); BundlesFolderRelativeToSource = Path.GetRelativePath(Build.DocumentationSourceDirectory.FullName, BundlesFolderPath); if (!Build.ReadFileSystem.Directory.Exists(BundlesFolderPath)) @@ -135,116 +215,87 @@ private void ExtractBundlesFolderPath() Found = true; } - private void LoadAndCacheBundles() + /// + /// Loads the changelog configuration to extract publish blockers. + /// Attempts to load from: + /// 1. Explicit :config: path if specified + /// 2. changelog.yml in the docset root + /// 3. docs/changelog.yml relative to docset root + /// + private void LoadConfiguration() { - if (BundlesFolderPath is null) - return; - var fileSystem = Build.ReadFileSystem; + string? configPath = null; - var yamlFiles = fileSystem.Directory - .EnumerateFiles(BundlesFolderPath, "*.yaml") - .Concat(fileSystem.Directory.EnumerateFiles(BundlesFolderPath, "*.yml")) - .ToList(); - - var loadedBundles = new List(); - - foreach (var bundleFile in yamlFiles) + // Try explicit config path first + if (!string.IsNullOrWhiteSpace(ConfigPath)) { - var bundleData = LoadBundle(bundleFile); - if (bundleData == null) - continue; - - var version = GetVersionFromBundle(bundleData) ?? Path.GetFileNameWithoutExtension(bundleFile); - var repo = bundleData.Products.Count > 0 - ? bundleData.Products[0].ProductId - : "elastic"; - - var entries = ResolveEntries(bundleData, bundleFile); + var explicitPath = Build.DocumentationSourceDirectory.ResolvePathFrom(ConfigPath); - loadedBundles.Add(new LoadedBundle(version, repo, bundleData, bundleFile, entries)); + if (fileSystem.File.Exists(explicitPath)) + configPath = explicitPath; + else + this.EmitWarning($"Specified changelog config path '{ConfigPath}' not found."); + } + else + { + // Auto-discover: try changelog.yml first, then docs/changelog.yml + var changelogYml = Build.DocumentationSourceDirectory.ResolvePathFrom("changelog.yml"); + var docsChangelogYml = Build.DocumentationSourceDirectory.ResolvePathFrom("docs/changelog.yml"); + + if (fileSystem.File.Exists(changelogYml)) + configPath = changelogYml; + else if (fileSystem.File.Exists(docsChangelogYml)) + configPath = docsChangelogYml; } - // Sort by semver (descending - newest first) - LoadedBundles = loadedBundles - .OrderByDescending(b => ParseVersion(b.Version)) - .ToList(); + if (string.IsNullOrWhiteSpace(configPath)) + return; + + PublishBlocker = ReleaseNotesSerialization.LoadPublishBlocker(fileSystem, configPath); } - private Bundle? LoadBundle(string filePath) + /// + /// Loads private repository names from assembler configuration. + /// Links to private repositories will be hidden in the rendered output. + /// + private void LoadPrivateRepositories() { try { - var bundleContent = Build.ReadFileSystem.File.ReadAllText(filePath); - return ChangelogYamlSerialization.DeserializeBundle(bundleContent); + // Try to load assembler configuration to get private repositories + var assemblerConfig = AssemblyConfiguration.Create(Build.ConfigurationFileProvider); + foreach (var repoName in assemblerConfig.PrivateRepositories.Keys) + _ = PrivateRepositories.Add(repoName); } - catch (YamlException e) + catch { - var fileName = Path.GetFileName(filePath); - this.EmitWarning($"Failed to parse changelog bundle '{fileName}': {e.Message}"); - return null; + // If assembler.yml is not available (standalone builds), no repos are private + // This is expected behavior - we silently continue with empty private repos } } - private static string? GetVersionFromBundle(Bundle bundledData) => - bundledData.Products.Count > 0 ? bundledData.Products[0].Target : null; - - private static SemVersion ParseVersion(string version) => - SemVersion.TryParse(version, out var semVersion) ? semVersion : ZeroVersion.Instance; - - private List ResolveEntries(Bundle bundledData, string bundleFilePath) + private void LoadAndCacheBundles() { - var entries = new List(); - var bundleDirectory = Path.GetDirectoryName(bundleFilePath) - ?? Build.DocumentationSourceDirectory.FullName; - - // Default changelog directory is parent of bundles folder - var changelogDirectory = Path.GetDirectoryName(bundleDirectory) - ?? Build.DocumentationSourceDirectory.FullName; - - foreach (var entry in bundledData.Entries) - { - ChangelogEntry? entryData = null; - - // If entry has resolved/inline data, use it directly - if (!string.IsNullOrWhiteSpace(entry.Title) && entry.Type != null) - { - entryData = ChangelogYamlSerialization.ConvertBundledEntry(entry); - } - else if (!string.IsNullOrWhiteSpace(entry.File?.Name)) - { - // Load from file reference - look in changelog directory (parent of bundles) - var filePath = Path.Combine(changelogDirectory, entry.File.Name); - - if (!Build.ReadFileSystem.File.Exists(filePath)) - { - this.EmitWarning($"Referenced changelog file '{entry.File.Name}' not found at '{filePath}'."); - continue; - } - - try - { - var fileContent = Build.ReadFileSystem.File.ReadAllText(filePath); + if (BundlesFolderPath is null) + return; - // Skip comment lines and normalize version to target - var yamlLines = fileContent.Split('\n'); - var yamlWithoutComments = string.Join('\n', yamlLines.Where(line => !line.TrimStart().StartsWith('#'))); - var normalizedYaml = VersionToTargetRegex().Replace(yamlWithoutComments, "$1target:"); + var loader = new BundleLoader(Build.ReadFileSystem); - entryData = ChangelogYamlSerialization.DeserializeEntry(normalizedYaml); - } - catch (YamlException e) - { - this.EmitWarning($"Failed to parse changelog file '{entry.File.Name}': {e.Message}"); - continue; - } - } + // Load bundles using the BundleLoader service + var loadedBundles = loader.LoadBundles( + BundlesFolderPath, + msg => this.EmitWarning(msg)); - if (entryData != null) - entries.Add(entryData); - } + // Sort by version (descending - newest first) + // Supports both semver (e.g., "9.3.0") and date-based (e.g., "2025-08-05") versions + var sortedBundles = loadedBundles + .OrderByDescending(b => VersionOrDate.Parse(b.Version)) + .ToList(); - return entries; + // Always merge bundles with the same target version + // (e.g., Cloud Serverless with multiple repos contributing to a single dated release) + LoadedBundles = loader.MergeBundlesByTarget(sortedBundles); } private IEnumerable ComputeGeneratedAnchors() @@ -259,39 +310,161 @@ private IEnumerable ComputeGeneratedAnchors() .GroupBy(e => e.Type) .ToDictionary(g => g.Key, g => g.Count()); + // Apply type filter to determine which sections to include + var shouldInclude = CreateTypeFilterPredicate(); + + // Critical sections + if (shouldInclude(ChangelogEntryType.BreakingChange) && entriesByType.ContainsKey(ChangelogEntryType.BreakingChange)) + yield return $"{repo}-{titleSlug}-breaking-changes"; + + if (shouldInclude(ChangelogEntryType.Security) && entriesByType.ContainsKey(ChangelogEntryType.Security)) + yield return $"{repo}-{titleSlug}-security"; + + if (shouldInclude(ChangelogEntryType.KnownIssue) && entriesByType.ContainsKey(ChangelogEntryType.KnownIssue)) + yield return $"{repo}-{titleSlug}-known-issues"; + + if (shouldInclude(ChangelogEntryType.Deprecation) && entriesByType.ContainsKey(ChangelogEntryType.Deprecation)) + yield return $"{repo}-{titleSlug}-deprecations"; + // Features and enhancements section - if (entriesByType.ContainsKey(ChangelogEntryType.Feature) || - entriesByType.ContainsKey(ChangelogEntryType.Enhancement)) + if (shouldInclude(ChangelogEntryType.Feature) && + (entriesByType.ContainsKey(ChangelogEntryType.Feature) || + entriesByType.ContainsKey(ChangelogEntryType.Enhancement))) yield return $"{repo}-{titleSlug}-features-enhancements"; - // Fixes section - if (entriesByType.ContainsKey(ChangelogEntryType.Security) || - entriesByType.ContainsKey(ChangelogEntryType.BugFix)) + // Fixes section (bug fixes only, security is separate) + if (shouldInclude(ChangelogEntryType.BugFix) && entriesByType.ContainsKey(ChangelogEntryType.BugFix)) yield return $"{repo}-{titleSlug}-fixes"; // Documentation section - if (entriesByType.ContainsKey(ChangelogEntryType.Docs)) + if (shouldInclude(ChangelogEntryType.Docs) && entriesByType.ContainsKey(ChangelogEntryType.Docs)) yield return $"{repo}-{titleSlug}-docs"; // Regressions section - if (entriesByType.ContainsKey(ChangelogEntryType.Regression)) + if (shouldInclude(ChangelogEntryType.Regression) && entriesByType.ContainsKey(ChangelogEntryType.Regression)) yield return $"{repo}-{titleSlug}-regressions"; // Other changes section - if (entriesByType.ContainsKey(ChangelogEntryType.Other)) + if (shouldInclude(ChangelogEntryType.Other) && entriesByType.ContainsKey(ChangelogEntryType.Other)) yield return $"{repo}-{titleSlug}-other"; + } + } - // Breaking changes section - if (entriesByType.ContainsKey(ChangelogEntryType.BreakingChange)) - yield return $"{repo}-{titleSlug}-breaking-changes"; + /// + /// Creates a predicate that returns true if the given entry type should be included based on the TypeFilter. + /// + private Func CreateTypeFilterPredicate() => + TypeFilter switch + { + ChangelogTypeFilter.All => _ => true, + ChangelogTypeFilter.BreakingChange => type => type == ChangelogEntryType.BreakingChange, + ChangelogTypeFilter.Deprecation => type => type == ChangelogEntryType.Deprecation, + ChangelogTypeFilter.KnownIssue => type => type == ChangelogEntryType.KnownIssue, + _ => type => !SeparatedTypes.Contains(type) // Default: exclude separated types + }; + + private IEnumerable ComputeTableOfContent() + { + foreach (var bundle in LoadedBundles) + { + var titleSlug = ChangelogTextUtilities.TitleToSlug(bundle.Version); + var repo = bundle.Repo; - // Deprecations section - if (entriesByType.ContainsKey(ChangelogEntryType.Deprecation)) - yield return $"{repo}-{titleSlug}-deprecations"; + // Version header + yield return new PageTocItem + { + Heading = bundle.Version, + Slug = titleSlug, + Level = 2 + }; - // Known issues section - if (entriesByType.ContainsKey(ChangelogEntryType.KnownIssue)) - yield return $"{repo}-{titleSlug}-known-issues"; + // Group entries by type to determine which sections will exist + var entriesByType = bundle.Entries + .GroupBy(e => e.Type) + .ToDictionary(g => g.Key, g => g.Count()); + + // Apply type filter to determine which sections to include + var shouldInclude = CreateTypeFilterPredicate(); + + // Critical sections first (new ordering) - all at h3 level (children of version) + if (shouldInclude(ChangelogEntryType.BreakingChange) && entriesByType.ContainsKey(ChangelogEntryType.BreakingChange)) + yield return new PageTocItem + { + Heading = "Breaking changes", + Slug = $"{repo}-{titleSlug}-breaking-changes", + Level = 3 + }; + + if (shouldInclude(ChangelogEntryType.Security) && entriesByType.ContainsKey(ChangelogEntryType.Security)) + yield return new PageTocItem + { + Heading = "Security", + Slug = $"{repo}-{titleSlug}-security", + Level = 3 + }; + + if (shouldInclude(ChangelogEntryType.KnownIssue) && entriesByType.ContainsKey(ChangelogEntryType.KnownIssue)) + yield return new PageTocItem + { + Heading = "Known issues", + Slug = $"{repo}-{titleSlug}-known-issues", + Level = 3 + }; + + if (shouldInclude(ChangelogEntryType.Deprecation) && entriesByType.ContainsKey(ChangelogEntryType.Deprecation)) + yield return new PageTocItem + { + Heading = "Deprecations", + Slug = $"{repo}-{titleSlug}-deprecations", + Level = 3 + }; + + // Features and enhancements section + if (shouldInclude(ChangelogEntryType.Feature) && + (entriesByType.ContainsKey(ChangelogEntryType.Feature) || + entriesByType.ContainsKey(ChangelogEntryType.Enhancement))) + yield return new PageTocItem + { + Heading = "Features and enhancements", + Slug = $"{repo}-{titleSlug}-features-enhancements", + Level = 3 + }; + + // Fixes section (bug fixes only, security is separate) + if (shouldInclude(ChangelogEntryType.BugFix) && entriesByType.ContainsKey(ChangelogEntryType.BugFix)) + yield return new PageTocItem + { + Heading = "Fixes", + Slug = $"{repo}-{titleSlug}-fixes", + Level = 3 + }; + + // Documentation section + if (shouldInclude(ChangelogEntryType.Docs) && entriesByType.ContainsKey(ChangelogEntryType.Docs)) + yield return new PageTocItem + { + Heading = "Documentation", + Slug = $"{repo}-{titleSlug}-docs", + Level = 3 + }; + + // Regressions section + if (shouldInclude(ChangelogEntryType.Regression) && entriesByType.ContainsKey(ChangelogEntryType.Regression)) + yield return new PageTocItem + { + Heading = "Regressions", + Slug = $"{repo}-{titleSlug}-regressions", + Level = 3 + }; + + // Other changes section + if (shouldInclude(ChangelogEntryType.Other) && entriesByType.ContainsKey(ChangelogEntryType.Other)) + yield return new PageTocItem + { + Heading = "Other changes", + Slug = $"{repo}-{titleSlug}-other", + Level = 3 + }; } } } diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs index 2c29b256a..4cde10f78 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs @@ -4,8 +4,8 @@ using System.Globalization; using System.Text; -using Elastic.Changelog; using Elastic.Documentation; +using Elastic.Documentation.ReleaseNotes; namespace Elastic.Markdown.Myst.Directives.Changelog; @@ -21,6 +21,7 @@ public static class ChangelogInlineRenderer return "_No changelog entries._"; var sb = new StringBuilder(); + var typeFilter = block.TypeFilter; // Render each bundle as a version section (already sorted by semver descending) var isFirst = true; @@ -29,7 +30,12 @@ public static class ChangelogInlineRenderer if (!isFirst) _ = sb.AppendLine(); - var bundleMarkdown = RenderSingleBundle(bundle); + var bundleMarkdown = RenderSingleBundle( + bundle, + block.Subsections, + block.PublishBlocker, + block.PrivateRepositories, + typeFilter); _ = sb.Append(bundleMarkdown); isFirst = false; @@ -38,23 +44,84 @@ public static class ChangelogInlineRenderer return sb.ToString(); } - private static string RenderSingleBundle(LoadedBundle bundle) + private static string RenderSingleBundle( + LoadedBundle bundle, + bool subsections, + PublishBlocker? publishBlocker, + HashSet privateRepositories, + ChangelogTypeFilter typeFilter) { var titleSlug = ChangelogTextUtilities.TitleToSlug(bundle.Version); + // Filter entries based on publish blocker configuration + var filteredEntries = FilterEntries(bundle.Entries, publishBlocker); + + // Apply type filter + filteredEntries = FilterEntriesByType(filteredEntries, typeFilter); + // Group entries by type - var entriesByType = bundle.Entries + var entriesByType = filteredEntries .GroupBy(e => e.Type) .ToDictionary(g => g.Key, g => g.ToList()); - return GenerateMarkdown(bundle.Version, titleSlug, bundle.Repo, entriesByType); + // Check if the bundle's repo (which may be merged like "elasticsearch+kibana") + // contains any private repositories - if so, hide links for this bundle + var hideLinks = ShouldHideLinksForRepo(bundle.Repo, privateRepositories); + + return GenerateMarkdown(bundle.Version, titleSlug, bundle.Repo, entriesByType, subsections, hideLinks); + } + + /// + /// Filters entries based on the type filter. + /// + private static IReadOnlyList FilterEntriesByType( + IReadOnlyList entries, + ChangelogTypeFilter typeFilter) => typeFilter switch + { + ChangelogTypeFilter.All => entries, + ChangelogTypeFilter.BreakingChange => entries.Where(e => e.Type == ChangelogEntryType.BreakingChange).ToList(), + ChangelogTypeFilter.Deprecation => entries.Where(e => e.Type == ChangelogEntryType.Deprecation).ToList(), + ChangelogTypeFilter.KnownIssue => entries.Where(e => e.Type == ChangelogEntryType.KnownIssue).ToList(), + _ => entries.Where(e => !ChangelogBlock.SeparatedTypes.Contains(e.Type)).ToList() // Default: exclude separated types + }; + + /// + /// Determines if links should be hidden for a bundle based on its repository. + /// For merged bundles (e.g., "elasticsearch+kibana+private-repo"), returns true + /// if ANY component repository is in the private repositories set. + /// + public static bool ShouldHideLinksForRepo(string bundleRepo, HashSet privateRepositories) + { + if (privateRepositories.Count == 0) + return false; + + // Split on '+' to handle merged bundles (e.g., "elasticsearch+kibana+private-repo") + var repos = bundleRepo.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + // Hide links if ANY component repo is private + return repos.Any(privateRepositories.Contains); + } + + /// + /// Filters entries based on publish blocker configuration. + /// + private static IReadOnlyList FilterEntries( + IReadOnlyList entries, + PublishBlocker? publishBlocker) + { + if (publishBlocker is not { HasBlockingRules: true }) + return entries; + + return entries.Where(e => !publishBlocker.ShouldBlock(e)).ToList(); } private static string GenerateMarkdown( string title, string titleSlug, string repo, - Dictionary> entriesByType) + Dictionary> entriesByType, + bool subsections, + bool hideLinks) { var sb = new StringBuilder(); @@ -70,91 +137,81 @@ private static string GenerateMarkdown( var deprecations = entriesByType.GetValueOrDefault(ChangelogEntryType.Deprecation, []); var knownIssues = entriesByType.GetValueOrDefault(ChangelogEntryType.KnownIssue, []); - // Build header with links to other sections if they exist _ = sb.AppendLine(CultureInfo.InvariantCulture, $"## {title}"); - var otherLinks = new List(); - if (knownIssues.Count > 0) - otherLinks.Add($"[Known issues](#{repo}-{titleSlug}-known-issues)"); - if (breakingChanges.Count > 0) - otherLinks.Add($"[Breaking changes](#{repo}-{titleSlug}-breaking-changes)"); - if (deprecations.Count > 0) - otherLinks.Add($"[Deprecations](#{repo}-{titleSlug}-deprecations)"); + // Check if we have any content at all + var hasAnyContent = features.Count > 0 || enhancements.Count > 0 || security.Count > 0 || + bugFixes.Count > 0 || docs.Count > 0 || regressions.Count > 0 || other.Count > 0 || + breakingChanges.Count > 0 || deprecations.Count > 0 || knownIssues.Count > 0; - if (otherLinks.Count > 0) + if (!hasAnyContent) { - var linksText = string.Join(" and ", otherLinks); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"_{linksText}._"); - _ = sb.AppendLine(); + _ = sb.AppendLine("_No new features, enhancements, or fixes._"); + return sb.ToString(); } - // Render main content sections - var hasMainContent = features.Count > 0 || enhancements.Count > 0 || security.Count > 0 || - bugFixes.Count > 0 || docs.Count > 0 || regressions.Count > 0 || other.Count > 0; - - if (hasMainContent) + if (breakingChanges.Count > 0) { - if (features.Count > 0 || enhancements.Count > 0) - { - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Features and enhancements [{repo}-{titleSlug}-features-enhancements]"); - var combined = features.Concat(enhancements).ToList(); - RenderEntriesByArea(sb, combined, repo); - } + _ = sb.AppendLine(); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Breaking changes [{repo}-{titleSlug}-breaking-changes]"); + RenderDetailedEntries(sb, breakingChanges, repo, groupBySubtype: true, hideLinks); + } - if (security.Count > 0 || bugFixes.Count > 0) - { - _ = sb.AppendLine(); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Fixes [{repo}-{titleSlug}-fixes]"); - var combined = security.Concat(bugFixes).ToList(); - RenderEntriesByArea(sb, combined, repo); - } + if (security.Count > 0) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Security [{repo}-{titleSlug}-security]"); + RenderEntriesByArea(sb, security, repo, subsections, hideLinks); + } - if (docs.Count > 0) - { - _ = sb.AppendLine(); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Documentation [{repo}-{titleSlug}-docs]"); - RenderEntriesByArea(sb, docs, repo); - } + if (knownIssues.Count > 0) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Known issues [{repo}-{titleSlug}-known-issues]"); + RenderDetailedEntries(sb, knownIssues, repo, groupBySubtype: false, hideLinks); + } - if (regressions.Count > 0) - { - _ = sb.AppendLine(); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Regressions [{repo}-{titleSlug}-regressions]"); - RenderEntriesByArea(sb, regressions, repo); - } + if (deprecations.Count > 0) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Deprecations [{repo}-{titleSlug}-deprecations]"); + RenderDetailedEntries(sb, deprecations, repo, groupBySubtype: false, hideLinks); + } - if (other.Count > 0) - { - _ = sb.AppendLine(); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Other changes [{repo}-{titleSlug}-other]"); - RenderEntriesByArea(sb, other, repo); - } + if (features.Count > 0 || enhancements.Count > 0) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Features and enhancements [{repo}-{titleSlug}-features-enhancements]"); + var combined = features.Concat(enhancements).ToList(); + RenderEntriesByArea(sb, combined, repo, subsections, hideLinks); } - else + + if (bugFixes.Count > 0) { - _ = sb.AppendLine("_No new features, enhancements, or fixes._"); + _ = sb.AppendLine(); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Fixes [{repo}-{titleSlug}-fixes]"); + RenderEntriesByArea(sb, bugFixes, repo, subsections, hideLinks); } - // Render special sections - if (breakingChanges.Count > 0) + if (docs.Count > 0) { _ = sb.AppendLine(); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Breaking changes [{repo}-{titleSlug}-breaking-changes]"); - RenderDetailedEntries(sb, breakingChanges, repo, groupBySubtype: true); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Documentation [{repo}-{titleSlug}-docs]"); + RenderEntriesByArea(sb, docs, repo, subsections, hideLinks); } - if (deprecations.Count > 0) + if (regressions.Count > 0) { _ = sb.AppendLine(); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Deprecations [{repo}-{titleSlug}-deprecations]"); - RenderDetailedEntries(sb, deprecations, repo, groupBySubtype: false); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Regressions [{repo}-{titleSlug}-regressions]"); + RenderEntriesByArea(sb, regressions, repo, subsections, hideLinks); } - if (knownIssues.Count > 0) + if (other.Count > 0) { _ = sb.AppendLine(); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Known issues [{repo}-{titleSlug}-known-issues]"); - RenderDetailedEntries(sb, knownIssues, repo, groupBySubtype: false); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Other changes [{repo}-{titleSlug}-other]"); + RenderEntriesByArea(sb, other, repo, subsections, hideLinks); } return sb.ToString(); @@ -163,60 +220,91 @@ private static string GenerateMarkdown( private static void RenderEntriesByArea( StringBuilder sb, List entries, - string repo) + string repo, + bool subsections, + bool hideLinks) { - // Always group by area and sort - var groupedByArea = entries.GroupBy(GetComponent).OrderBy(g => g.Key).ToList(); - - foreach (var areaGroup in groupedByArea) + if (subsections) { - if (!string.IsNullOrWhiteSpace(areaGroup.Key)) - { - var header = ChangelogTextUtilities.FormatAreaHeader(areaGroup.Key); - _ = sb.AppendLine(); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"**{header}**"); - } + // Group by area and sort when subsections is enabled + var groupedByArea = entries.GroupBy(GetComponent).OrderBy(g => g.Key).ToList(); - foreach (var entry in areaGroup) + foreach (var areaGroup in groupedByArea) { - _ = sb.Append("* "); - _ = sb.Append(ChangelogTextUtilities.Beautify(entry.Title)); - - _ = sb.Append(' '); - if (!string.IsNullOrWhiteSpace(entry.Pr)) + if (!string.IsNullOrWhiteSpace(areaGroup.Key)) { - _ = sb.Append(ChangelogTextUtilities.FormatPrLink(entry.Pr, repo, hidePrivateLinks: false)); - _ = sb.Append(' '); + var header = ChangelogTextUtilities.FormatAreaHeader(areaGroup.Key); + _ = sb.AppendLine(); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"**{header}**"); } - if (entry.Issues != null && entry.Issues.Count > 0) - { - foreach (var issue in entry.Issues) - { - _ = sb.Append(ChangelogTextUtilities.FormatIssueLink(issue, repo, hidePrivateLinks: false)); - _ = sb.Append(' '); - } - } + foreach (var entry in areaGroup) + RenderSingleEntry(sb, entry, repo, hideLinks); + } + } + else + { + foreach (var entry in entries) + RenderSingleEntry(sb, entry, repo, hideLinks); + } + } - if (!string.IsNullOrWhiteSpace(entry.Description)) - { - _ = sb.AppendLine(); - var indented = ChangelogTextUtilities.Indent(entry.Description); - _ = sb.AppendLine(indented); - } - else - { - _ = sb.AppendLine(); - } + private static void RenderSingleEntry(StringBuilder sb, ChangelogEntry entry, string repo, bool hideLinks) + { + _ = sb.Append("* "); + _ = sb.Append(ChangelogTextUtilities.Beautify(entry.Title)); + + RenderEntryLinks(sb, entry, repo, hideLinks); + + if (!string.IsNullOrWhiteSpace(entry.Description)) + { + var indented = ChangelogTextUtilities.Indent(entry.Description); + _ = sb.AppendLine(indented); + } + } + + private static void RenderEntryLinks(StringBuilder sb, ChangelogEntry entry, string repo, bool hideLinks) + { + var hasPr = !string.IsNullOrWhiteSpace(entry.Pr); + + if (hideLinks) + { + // When hiding links, put them on separate lines as comments + _ = sb.AppendLine(); + if (hasPr) + { + _ = sb.Append(" "); + _ = sb.AppendLine(ChangelogTextUtilities.FormatPrLink(entry.Pr!, repo, hidePrivateLinks: true)); + } + foreach (var issue in entry.Issues ?? []) + { + _ = sb.Append(" "); + _ = sb.AppendLine(ChangelogTextUtilities.FormatIssueLink(issue, repo, hidePrivateLinks: true)); } + return; + } + + // Default: render links inline + _ = sb.Append(' '); + if (hasPr) + { + _ = sb.Append(ChangelogTextUtilities.FormatPrLink(entry.Pr!, repo, hidePrivateLinks: false)); + _ = sb.Append(' '); + } + foreach (var issue in entry.Issues ?? []) + { + _ = sb.Append(ChangelogTextUtilities.FormatIssueLink(issue, repo, hidePrivateLinks: false)); + _ = sb.Append(' '); } + _ = sb.AppendLine(); } private static void RenderDetailedEntries( StringBuilder sb, List entries, string repo, - bool groupBySubtype) + bool groupBySubtype, + bool hideLinks) { var grouped = groupBySubtype ? entries.GroupBy(e => e.Subtype?.ToStringFast(true) ?? string.Empty).OrderBy(g => g.Key).ToList() @@ -234,48 +322,64 @@ private static void RenderDetailedEntries( } foreach (var entry in group) - { - _ = sb.AppendLine(); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}"); - _ = sb.AppendLine(entry.Description ?? "% Describe the change"); - _ = sb.AppendLine(); + RenderDetailedEntry(sb, entry, repo, hideLinks); + } + } - // PR/Issue links - var hasPr = !string.IsNullOrWhiteSpace(entry.Pr); - var hasIssues = entry.Issues is { Count: > 0 }; - if (hasPr || hasIssues) - { - _ = sb.Append("For more information, check "); - if (hasPr) - { - _ = sb.Append(ChangelogTextUtilities.FormatPrLink(entry.Pr!, repo, hidePrivateLinks: false)); - } - if (hasIssues) - { - foreach (var issue in entry.Issues!) - { - _ = sb.Append(' '); - _ = sb.Append(ChangelogTextUtilities.FormatIssueLink(issue, repo, hidePrivateLinks: false)); - } - } - _ = sb.AppendLine("."); - _ = sb.AppendLine(); - } + private static void RenderDetailedEntry(StringBuilder sb, ChangelogEntry entry, string repo, bool hideLinks) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}"); + _ = sb.AppendLine(entry.Description ?? "% Describe the change"); + _ = sb.AppendLine(); - // Impact section - _ = sb.AppendLine(!string.IsNullOrWhiteSpace(entry.Impact) - ? "**Impact**
" + entry.Impact - : "% **Impact**
_Add a description of the impact_"); - _ = sb.AppendLine(); + RenderDetailedEntryLinks(sb, entry, repo, hideLinks); - // Action section - _ = sb.AppendLine(!string.IsNullOrWhiteSpace(entry.Action) - ? "**Action**
" + entry.Action - : "% **Action**
_Add a description of what action to take_"); + // Impact section + _ = sb.AppendLine(!string.IsNullOrWhiteSpace(entry.Impact) + ? "**Impact**
" + entry.Impact + : "% **Impact**
_Add a description of the impact_"); + _ = sb.AppendLine(); - _ = sb.AppendLine("::::"); - } + // Action section + _ = sb.AppendLine(!string.IsNullOrWhiteSpace(entry.Action) + ? "**Action**
" + entry.Action + : "% **Action**
_Add a description of what action to take_"); + + _ = sb.AppendLine("::::"); + } + + private static void RenderDetailedEntryLinks(StringBuilder sb, ChangelogEntry entry, string repo, bool hideLinks) + { + var hasPr = !string.IsNullOrWhiteSpace(entry.Pr); + var hasIssues = entry.Issues is { Count: > 0 }; + + if (!hasPr && !hasIssues) + return; + + if (hideLinks) + { + // When hiding links, put them on separate lines as comments + if (hasPr) + _ = sb.AppendLine(ChangelogTextUtilities.FormatPrLink(entry.Pr!, repo, hidePrivateLinks: true)); + foreach (var issue in entry.Issues ?? []) + _ = sb.AppendLine(ChangelogTextUtilities.FormatIssueLink(issue, repo, hidePrivateLinks: true)); + _ = sb.AppendLine("For more information, check the pull request or issue above."); + _ = sb.AppendLine(); + return; + } + + // Default: render links inline + _ = sb.Append("For more information, check "); + if (hasPr) + _ = sb.Append(ChangelogTextUtilities.FormatPrLink(entry.Pr!, repo, hidePrivateLinks: false)); + foreach (var issue in entry.Issues ?? []) + { + _ = sb.Append(' '); + _ = sb.Append(ChangelogTextUtilities.FormatIssueLink(issue, repo, hidePrivateLinks: false)); } + _ = sb.AppendLine("."); + _ = sb.AppendLine(); } private static string GetComponent(ChangelogEntry entry) => diff --git a/src/services/Elastic.Changelog/Bundling/BundleBuilder.cs b/src/services/Elastic.Changelog/Bundling/BundleBuilder.cs index 91bb37d1f..d50a8c227 100644 --- a/src/services/Elastic.Changelog/Bundling/BundleBuilder.cs +++ b/src/services/Elastic.Changelog/Bundling/BundleBuilder.cs @@ -2,9 +2,9 @@ // 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.Serialization; using Elastic.Documentation; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.ReleaseNotes; namespace Elastic.Changelog.Bundling; @@ -167,7 +167,7 @@ private static List BuildProducts( return null; } - var bundledEntry = ChangelogMapper.ToBundledEntry(data) with + var bundledEntry = data.ToBundledEntry() with { File = new BundledFile { diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs index 157e6e479..265b43fc4 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs @@ -6,9 +6,9 @@ using System.IO.Abstractions; using System.Text; using System.Text.RegularExpressions; -using Elastic.Changelog.Serialization; -using Elastic.Documentation; +using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -103,7 +103,7 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle }; // Serialize and write the amend file - var yaml = ChangelogYamlSerialization.SerializeBundle(amendBundle); + var yaml = ReleaseNotesSerialization.SerializeBundle(amendBundle); // Ensure output directory exists var outputDir = _fileSystem.Path.GetDirectoryName(amendFilePath); @@ -189,7 +189,7 @@ private string GenerateAmendFilePath(string bundlePath, int amendNumber) // Normalize "version:" to "target:" in products section var normalizedYaml = ChangelogBundlingService.VersionToTargetRegex().Replace(yamlWithoutComments, "$1target:"); - var entry = ChangelogYamlSerialization.DeserializeEntry(normalizedYaml); + var entry = ReleaseNotesSerialization.DeserializeEntry(normalizedYaml); return new BundledEntry { diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index c94831676..8e0dd4e19 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -7,11 +7,11 @@ using System.Text; using System.Text.RegularExpressions; using Elastic.Changelog.Configuration; -using Elastic.Changelog.Serialization; -using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -125,7 +125,7 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle var filterCriteria = BuildFilterCriteria(input, prFilterResult.PrsToMatch); // Match changelog entries - var entryMatcher = new ChangelogEntryMatcher(_fileSystem, ChangelogYamlSerialization.GetEntryDeserializer(), _logger); + var entryMatcher = new ChangelogEntryMatcher(_fileSystem, ReleaseNotesSerialization.GetEntryDeserializer(), _logger); var matchResult = await entryMatcher.MatchChangelogsAsync(collector, yamlFiles, filterCriteria, ctx); _logger.LogInformation("Found {Count} matching changelog entries", matchResult.Entries.Count); @@ -351,7 +351,7 @@ private static ChangelogFilterCriteria BuildFilterCriteria(BundleChangelogsArgum private async Task WriteBundleFileAsync(Bundle bundledData, string outputPath, Cancel ctx) { // Generate bundled YAML - var bundledYaml = ChangelogYamlSerialization.SerializeBundle(bundledData); + var bundledYaml = ReleaseNotesSerialization.SerializeBundle(bundledData); // Ensure output directory exists var outputDir = _fileSystem.Path.GetDirectoryName(outputPath); diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogEntryMatcher.cs b/src/services/Elastic.Changelog/Bundling/ChangelogEntryMatcher.cs index a450cbde3..6833a7a2b 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogEntryMatcher.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogEntryMatcher.cs @@ -3,9 +3,9 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using Elastic.Changelog.Serialization; -using Elastic.Documentation; +using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.ReleaseNotes; using Microsoft.Extensions.Logging; using YamlDotNet.Core; using YamlDotNet.Serialization; @@ -75,7 +75,7 @@ public async Task MatchChangelogsAsync( // Normalize "version:" to "target:" in products section for compatibility var normalizedYaml = ChangelogBundlingService.VersionToTargetRegex().Replace(yamlWithoutComments, "$1target:"); - var yamlDto = deserializer.Deserialize(normalizedYaml); + var yamlDto = deserializer.Deserialize(normalizedYaml); // Check for duplicates (using checksum) if (seenChangelogs.Contains(checksum)) @@ -92,7 +92,7 @@ public async Task MatchChangelogsAsync( _ = seenChangelogs.Add(checksum); // Convert to domain type - var data = ChangelogYamlSerialization.ConvertEntry(yamlDto); + var data = ReleaseNotesSerialization.ConvertEntry(yamlDto); return new MatchedChangelogFile { @@ -117,7 +117,7 @@ public async Task MatchChangelogsAsync( } private static bool MatchesFilter( - ChangelogEntryYaml data, + ChangelogEntryDto data, ChangelogFilterCriteria criteria, HashSet matchedPrs) { @@ -134,7 +134,7 @@ private static bool MatchesFilter( } private static bool MatchesProductFilter( - ChangelogEntryYaml data, + ChangelogEntryDto data, IReadOnlyList productFilters) { if (data.Products == null || data.Products.Count == 0) @@ -158,7 +158,7 @@ private static bool MatchesProductFilter( } private static bool MatchesPrFilter( - ChangelogEntryYaml data, + ChangelogEntryDto data, ChangelogFilterCriteria criteria, HashSet matchedPrs) { diff --git a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs index 5c0dc5b1f..eb441f561 100644 --- a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs +++ b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs @@ -9,8 +9,11 @@ using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.ReleaseNotes; using Microsoft.Extensions.Logging; using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; namespace Elastic.Changelog.Configuration; @@ -21,6 +24,35 @@ public class ChangelogConfigurationLoader(ILoggerFactory logFactory, IConfigurat { private readonly ILogger _logger = logFactory.CreateLogger(); + private static readonly IDeserializer ConfigurationDeserializer = + new StaticDeserializerBuilder(new ChangelogYamlStaticContext()) + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .WithTypeConverter(new TypeEntryYamlConverter()) + .Build(); + + /// + /// Deserializes changelog configuration YAML content. + /// + internal static ChangelogConfigurationYaml DeserializeConfiguration(string yaml) => + ConfigurationDeserializer.Deserialize(yaml); + + /// + /// Loads the publish blocker configuration from a changelog. + /// + /// The file system to read from. + /// The path to the changelog.yml configuration file. + /// The publish blocker configuration, or null if not found. + public static PublishBlocker? LoadPublishBlocker(IFileSystem fileSystem, string configPath) + { + if (!fileSystem.File.Exists(configPath)) + return null; + + var yamlContent = fileSystem.File.ReadAllText(configPath); + var yamlConfig = DeserializeConfiguration(yamlContent); + + return ParsePublishBlocker(yamlConfig.Block?.Publish); + } + /// /// Loads changelog configuration from file or returns default configuration /// @@ -39,7 +71,7 @@ public class ChangelogConfigurationLoader(ILoggerFactory logFactory, IConfigurat try { var yamlContent = await fileSystem.File.ReadAllTextAsync(finalConfigPath, ctx); - var yamlConfig = ChangelogYamlSerialization.DeserializeConfiguration(yamlContent); + var yamlConfig = DeserializeConfiguration(yamlContent); return ParseConfiguration(collector, yamlConfig, finalConfigPath); } diff --git a/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs b/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs index ec6bb4d96..9c15af8e4 100644 --- a/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs +++ b/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs @@ -4,10 +4,11 @@ using System.IO.Abstractions; using System.Text; -using Elastic.Changelog.Serialization; using Elastic.Documentation; using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.ReleaseNotes; using Microsoft.Extensions.Logging; namespace Elastic.Changelog.Creation; @@ -97,7 +98,7 @@ private static ChangelogEntry BuildChangelogData(CreateChangelogArguments input, FeatureId = input.FeatureId, Highlight = input.Highlight, Pr = prUrl ?? (input.Prs != null && input.Prs.Length > 0 ? input.Prs[0] : null), - Products = input.Products.Select(ChangelogMapper.ToProductReference).ToList(), + Products = input.Products.Select(p => p.ToProductReference()).ToList(), Areas = input.Areas is { Length: > 0 } ? input.Areas.ToList() : null, Issues = input.Issues is { Length: > 0 } ? input.Issues.ToList() : null }; @@ -119,7 +120,7 @@ private static string GenerateYaml(ChangelogEntry data, ChangelogConfiguration c }; // Use centralized serialization which handles DTO conversion - var yaml = ChangelogYamlSerialization.SerializeEntry(serializeData); + var yaml = ReleaseNotesSerialization.SerializeEntry(serializeData); // Comment out missing title/type fields - insert at the beginning of the YAML data if (titleMissing || typeMissing) diff --git a/src/services/Elastic.Changelog/Creation/PrInfoProcessor.cs b/src/services/Elastic.Changelog/Creation/PrInfoProcessor.cs index 7d2028da4..914eca600 100644 --- a/src/services/Elastic.Changelog/Creation/PrInfoProcessor.cs +++ b/src/services/Elastic.Changelog/Creation/PrInfoProcessor.cs @@ -3,9 +3,9 @@ // See the LICENSE file in the project root for more information using Elastic.Changelog.GitHub; -using Elastic.Documentation; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.ReleaseNotes; using Microsoft.Extensions.Logging; namespace Elastic.Changelog.Creation; diff --git a/src/services/Elastic.Changelog/Elastic.Changelog.csproj b/src/services/Elastic.Changelog/Elastic.Changelog.csproj index 49b463211..4a18c159a 100644 --- a/src/services/Elastic.Changelog/Elastic.Changelog.csproj +++ b/src/services/Elastic.Changelog/Elastic.Changelog.csproj @@ -13,7 +13,6 @@ - diff --git a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs index 10084fff6..b5b2c06b2 100644 --- a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs +++ b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs @@ -5,14 +5,14 @@ using System.IO.Abstractions; using System.Security.Cryptography; using System.Text; -using Elastic.Changelog.Bundling; using Elastic.Changelog.Configuration; using Elastic.Changelog.GitHub; -using Elastic.Changelog.Serialization; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -283,7 +283,7 @@ private async Task ProcessPrReference( } private static string GenerateYaml(ChangelogEntry data) => - ChangelogYamlSerialization.SerializeEntry(data); + ReleaseNotesSerialization.SerializeEntry(data); private async Task CreateBundleFile( string outputDir, @@ -311,11 +311,11 @@ private async Task CreateBundleFile( var bundleData = new Bundle { - Products = [ChangelogMapper.ToBundledProduct(productInfo)], + Products = [productInfo.ToBundledProduct()], Entries = bundleEntries }; - var yamlContent = ChangelogYamlSerialization.SerializeBundle(bundleData); + var yamlContent = ReleaseNotesSerialization.SerializeBundle(bundleData); // Create bundles subfolder var bundlesDir = _fileSystem.Path.Combine(outputDir, "bundles"); @@ -380,19 +380,18 @@ private bool ShouldSkipPrDueToLabelBlockers( return false; var normalizedProductId = productInfo.Product?.Replace('_', '-') ?? string.Empty; - if (config.Block.ByProduct.TryGetValue(normalizedProductId, out var productBlockers)) + if (config.Block.ByProduct.TryGetValue(normalizedProductId, out var productBlockers) + && productBlockers.Create is { Count: > 0 }) { - if (productBlockers.Create != null && productBlockers.Create.Count > 0) + var matchingBlockerLabel = productBlockers.Create + .FirstOrDefault(blockerLabel => prLabels.Contains(blockerLabel, StringComparer.OrdinalIgnoreCase)); + + if (matchingBlockerLabel != null) { - var matchingBlockerLabel = productBlockers.Create - .FirstOrDefault(blockerLabel => prLabels.Contains(blockerLabel, StringComparer.OrdinalIgnoreCase)); - if (matchingBlockerLabel != null) - { - collector.EmitWarning(prUrl, - $"Skipping changelog creation for PR {prUrl} due to blocking label '{matchingBlockerLabel}' " + - $"for product '{productInfo.Product}'. This label is configured to prevent changelog creation for this product."); - return true; - } + collector.EmitWarning(prUrl, + $"Skipping changelog creation for PR {prUrl} due to blocking label '{matchingBlockerLabel}' " + + $"for product '{productInfo.Product}'. This label is configured to prevent changelog creation for this product."); + return true; } } diff --git a/src/services/Elastic.Changelog/ProductArgument.cs b/src/services/Elastic.Changelog/ProductArgument.cs index 8ed066f14..11d1e12de 100644 --- a/src/services/Elastic.Changelog/ProductArgument.cs +++ b/src/services/Elastic.Changelog/ProductArgument.cs @@ -2,6 +2,9 @@ // 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.Documentation; +using Elastic.Documentation.ReleaseNotes; + namespace Elastic.Changelog; /// @@ -18,4 +21,34 @@ public record ProductArgument /// Lifecycle string or wildcard pattern. public string? Lifecycle { get; init; } + + /// + /// Converts this ProductArgument to a ProductReference domain type. + /// + public ProductReference ToProductReference() => new() + { + ProductId = Product ?? "", + Target = Target, + Lifecycle = ParseLifecycle(Lifecycle) + }; + + /// + /// Converts this ProductArgument to a BundledProduct domain type. + /// + public BundledProduct ToBundledProduct() => new() + { + ProductId = Product ?? "", + Target = Target, + Lifecycle = ParseLifecycle(Lifecycle) + }; + + private static Lifecycle? ParseLifecycle(string? value) + { + if (string.IsNullOrEmpty(value)) + return null; + + return LifecycleExtensions.TryParse(value, out var result, ignoreCase: true, allowMatchingMetadataAttribute: true) + ? result + : null; + } } diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs index db209d6ce..439893a70 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs @@ -4,7 +4,7 @@ using System.Globalization; using System.Text; -using Elastic.Documentation; +using Elastic.Documentation.ReleaseNotes; namespace Elastic.Changelog.Rendering.Asciidoc; diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/BreakingChangesAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/BreakingChangesAsciidocRenderer.cs index b0acff1cb..0deb809dd 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/BreakingChangesAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/BreakingChangesAsciidocRenderer.cs @@ -2,9 +2,9 @@ // 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 System.Globalization; using System.Text; using Elastic.Documentation; +using Elastic.Documentation.ReleaseNotes; namespace Elastic.Changelog.Rendering.Asciidoc; diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs index 1079bdd7d..447dd967b 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs @@ -6,7 +6,6 @@ using System.IO.Abstractions; using System.Text; -using Elastic.Documentation; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ChangelogEntryType; diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs index 9b616120b..ea1f33ee8 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs @@ -2,9 +2,8 @@ // 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 System.Globalization; using System.Text; -using Elastic.Documentation; +using Elastic.Documentation.ReleaseNotes; namespace Elastic.Changelog.Rendering.Asciidoc; diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/EntriesByAreaAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/EntriesByAreaAsciidocRenderer.cs index 0d4ba1610..054b02ed2 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/EntriesByAreaAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/EntriesByAreaAsciidocRenderer.cs @@ -2,9 +2,8 @@ // 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 System.Globalization; using System.Text; -using Elastic.Documentation; +using Elastic.Documentation.ReleaseNotes; namespace Elastic.Changelog.Rendering.Asciidoc; diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/KnownIssuesAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/KnownIssuesAsciidocRenderer.cs index 9f02be72d..f3a8786ba 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/KnownIssuesAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/KnownIssuesAsciidocRenderer.cs @@ -2,9 +2,8 @@ // 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 System.Globalization; using System.Text; -using Elastic.Documentation; +using Elastic.Documentation.ReleaseNotes; namespace Elastic.Changelog.Rendering.Asciidoc; diff --git a/src/services/Elastic.Changelog/Rendering/BundleDataResolver.cs b/src/services/Elastic.Changelog/Rendering/BundleDataResolver.cs index d9aecda6e..5c05cf1e8 100644 --- a/src/services/Elastic.Changelog/Rendering/BundleDataResolver.cs +++ b/src/services/Elastic.Changelog/Rendering/BundleDataResolver.cs @@ -4,8 +4,8 @@ using System.IO.Abstractions; using Elastic.Changelog.Bundling; -using Elastic.Changelog.Serialization; -using Elastic.Documentation; +using Elastic.Documentation.Configuration.ReleaseNotes; +using Elastic.Documentation.ReleaseNotes; namespace Elastic.Changelog.Rendering; @@ -79,9 +79,9 @@ private async Task ResolveEntryAsync( string bundleDirectory, Cancel ctx) { - // If entry has resolved data, use Mapperly to convert + // If entry has resolved data, convert to ChangelogEntry if (!string.IsNullOrWhiteSpace(entry.Title) && entry.Type != null) - return ChangelogMapper.ToEntry(entry); + return ReleaseNotesSerialization.ConvertBundledEntry(entry); // Load from file (already validated to exist) var filePath = fileSystem.Path.Combine(bundleDirectory, entry.File!.Name); @@ -94,6 +94,6 @@ private async Task ResolveEntryAsync( // Normalize "version:" to "target:" in products section var normalizedYaml = ChangelogBundlingService.VersionToTargetRegex().Replace(yamlWithoutComments, "$1target:"); - return ChangelogYamlSerialization.DeserializeEntry(normalizedYaml); + return ReleaseNotesSerialization.DeserializeEntry(normalizedYaml); } } diff --git a/src/services/Elastic.Changelog/Rendering/BundleValidationResult.cs b/src/services/Elastic.Changelog/Rendering/BundleValidationResult.cs index 1c11d1a1b..5d909fa66 100644 --- a/src/services/Elastic.Changelog/Rendering/BundleValidationResult.cs +++ b/src/services/Elastic.Changelog/Rendering/BundleValidationResult.cs @@ -2,7 +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.Documentation; +using Elastic.Documentation.ReleaseNotes; namespace Elastic.Changelog.Rendering; diff --git a/src/services/Elastic.Changelog/Rendering/BundleValidationService.cs b/src/services/Elastic.Changelog/Rendering/BundleValidationService.cs index 2d51411e0..1231ea202 100644 --- a/src/services/Elastic.Changelog/Rendering/BundleValidationService.cs +++ b/src/services/Elastic.Changelog/Rendering/BundleValidationService.cs @@ -4,8 +4,9 @@ using System.IO.Abstractions; using Elastic.Changelog.Bundling; -using Elastic.Changelog.Serialization; +using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.ReleaseNotes; using Microsoft.Extensions.Logging; using YamlDotNet.Core; @@ -41,7 +42,7 @@ public async Task ValidateBundlesAsync( Bundle? bundledData; try { - bundledData = ChangelogYamlSerialization.DeserializeBundle(bundleContent); + bundledData = ReleaseNotesSerialization.DeserializeBundle(bundleContent); } catch (YamlException yamlEx) { @@ -101,7 +102,7 @@ public async Task ValidateBundlesAsync( try { var amendContent = await fileSystem.File.ReadAllTextAsync(amendFile, ctx); - var amendBundle = ChangelogYamlSerialization.DeserializeBundle(amendContent); + var amendBundle = ReleaseNotesSerialization.DeserializeBundle(amendContent); _logger.LogInformation("Merging {Count} entries from amend file {AmendFile}", amendBundle.Entries.Count, amendFile); mergedEntries.AddRange(amendBundle.Entries); @@ -259,7 +260,7 @@ private async Task ValidateFileReferenceEntryAsync( // Normalize "version:" to "target:" in products section var normalizedYaml = ChangelogBundlingService.VersionToTargetRegex().Replace(yamlWithoutComments, "$1target:"); - var entryData = ChangelogYamlSerialization.DeserializeEntry(normalizedYaml); + var entryData = ReleaseNotesSerialization.DeserializeEntry(normalizedYaml); // Validate required fields in changelog file if (string.IsNullOrWhiteSpace(entryData.Title)) diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs index 639f09f5f..cd054beb2 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs @@ -4,6 +4,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.ReleaseNotes; namespace Elastic.Changelog.Rendering; diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderUtilities.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderUtilities.cs index 2d1bc21c8..62b11f9b2 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderUtilities.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderUtilities.cs @@ -2,9 +2,8 @@ // 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; using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.ReleaseNotes; namespace Elastic.Changelog.Rendering; diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 7a7609bab..27a617e7a 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -4,13 +4,13 @@ using System.ComponentModel.DataAnnotations; using System.IO.Abstractions; -using System.Linq; using System.Text.Json.Serialization; using Elastic.Changelog.Configuration; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using NetEscapades.EnumGenerators; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs index 871943fe9..9ffafdca8 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using System.Text; using Elastic.Documentation; +using Elastic.Documentation.ReleaseNotes; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ChangelogEntryType; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs index c67ca1c82..94075d2a3 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs @@ -4,7 +4,7 @@ using System.IO.Abstractions; using System.Text; -using Elastic.Documentation; +using Elastic.Documentation.ReleaseNotes; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ChangelogEntryType; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index 5c121579e..3f9356430 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs @@ -4,7 +4,7 @@ using System.IO.Abstractions; using System.Text; -using Elastic.Documentation; +using Elastic.Documentation.ReleaseNotes; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ChangelogEntryType; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs index 9c5b27ae7..70b48af6d 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs @@ -4,7 +4,7 @@ using System.IO.Abstractions; using System.Text; -using Elastic.Documentation; +using Elastic.Documentation.ReleaseNotes; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ChangelogEntryType; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs index 05f5bf6a7..adb29380e 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs @@ -4,7 +4,7 @@ using System.IO.Abstractions; using System.Text; -using Elastic.Documentation; +using Elastic.Documentation.ReleaseNotes; namespace Elastic.Changelog.Rendering.Markdown; diff --git a/src/services/Elastic.Changelog/Rendering/ResolvedEntriesResult.cs b/src/services/Elastic.Changelog/Rendering/ResolvedEntriesResult.cs index 7476d4f1a..9bb86d923 100644 --- a/src/services/Elastic.Changelog/Rendering/ResolvedEntriesResult.cs +++ b/src/services/Elastic.Changelog/Rendering/ResolvedEntriesResult.cs @@ -2,7 +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.Documentation; +using Elastic.Documentation.ReleaseNotes; namespace Elastic.Changelog.Rendering; diff --git a/src/services/Elastic.Changelog/Serialization/ChangelogEntryTypeConverter.cs b/src/services/Elastic.Changelog/Serialization/ChangelogEntryTypeConverter.cs deleted file mode 100644 index 2537d591d..000000000 --- a/src/services/Elastic.Changelog/Serialization/ChangelogEntryTypeConverter.cs +++ /dev/null @@ -1,47 +0,0 @@ -// 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.Documentation; -using YamlDotNet.Core; -using YamlDotNet.Core.Events; -using YamlDotNet.Serialization; - -namespace Elastic.Changelog.Serialization; - -/// -/// YAML type converter for ChangelogEntryType that handles string serialization/deserialization. -/// Reads/writes the Display attribute value (e.g., "bug-fix" instead of "BugFix"). -/// -public class ChangelogEntryTypeConverter : IYamlTypeConverter -{ - public bool Accepts(Type type) => type == typeof(ChangelogEntryType); - - public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) - { - var scalar = parser.Consume(); - - if (string.IsNullOrEmpty(scalar.Value)) - return ChangelogEntryType.Invalid; - - // Try to parse using the extension method that supports Display attribute matching - if (ChangelogEntryTypeExtensions.TryParse(scalar.Value, out var result, ignoreCase: true, allowMatchingMetadataAttribute: true)) - return result; - - // Return Invalid for unrecognized type strings - will be caught by validation - return ChangelogEntryType.Invalid; - } - - public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) - { - if (value is not ChangelogEntryType entryType) - { - emitter.Emit(new Scalar(null, null, string.Empty, ScalarStyle.Plain, true, false)); - return; - } - - // Write the Display attribute value (e.g., "bug-fix" instead of "BugFix") - var stringValue = entryType.ToStringFast(true); - emitter.Emit(new Scalar(null, null, stringValue, ScalarStyle.Plain, true, false)); - } -} diff --git a/src/services/Elastic.Changelog/Serialization/ChangelogMapper.cs b/src/services/Elastic.Changelog/Serialization/ChangelogMapper.cs deleted file mode 100644 index c2665b631..000000000 --- a/src/services/Elastic.Changelog/Serialization/ChangelogMapper.cs +++ /dev/null @@ -1,139 +0,0 @@ -// 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.Documentation; -using Riok.Mapperly.Abstractions; - -namespace Elastic.Changelog.Serialization; - -/// -/// Source-generated mapper for converting between YAML DTOs and domain types. -/// -[Mapper(ThrowOnPropertyMappingNullMismatch = false)] -internal static partial class ChangelogMapper -{ - // YAML DTO to Domain mappings - [MapProperty(nameof(ChangelogEntryYaml.Type), nameof(ChangelogEntry.Type), Use = nameof(ParseEntryType))] - [MapProperty(nameof(ChangelogEntryYaml.Subtype), nameof(ChangelogEntry.Subtype), Use = nameof(ParseEntrySubtype))] - public static partial ChangelogEntry ToEntry(ChangelogEntryYaml yaml); - - [MapProperty(nameof(ProductInfoYaml.Product), nameof(ProductReference.ProductId), Use = nameof(NullToEmpty))] - [MapProperty(nameof(ProductInfoYaml.Lifecycle), nameof(ProductReference.Lifecycle), Use = nameof(ParseLifecycle))] - public static partial ProductReference ToProductReference(ProductInfoYaml yaml); - - // ProductArgument to domain type mappings - [MapProperty(nameof(ProductArgument.Product), nameof(ProductReference.ProductId), Use = nameof(NullToEmpty))] - [MapProperty(nameof(ProductArgument.Lifecycle), nameof(ProductReference.Lifecycle), Use = nameof(ParseLifecycle))] - public static partial ProductReference ToProductReference(ProductArgument arg); - - [MapProperty(nameof(ProductArgument.Product), nameof(BundledProduct.ProductId), Use = nameof(NullToEmpty))] - [MapProperty(nameof(ProductArgument.Lifecycle), nameof(BundledProduct.Lifecycle), Use = nameof(ParseLifecycle))] - public static partial BundledProduct ToBundledProduct(ProductArgument arg); - - // BundledEntry to ChangelogEntry mapping - [MapProperty(nameof(BundledEntry.Type), nameof(ChangelogEntry.Type), Use = nameof(BundledEntryTypeToEntryType))] - [MapperIgnoreSource(nameof(BundledEntry.File))] - public static partial ChangelogEntry ToEntry(BundledEntry entry); - - public static partial Bundle ToBundle(BundleYaml yaml); - - [MapProperty(nameof(BundledProductYaml.Product), nameof(BundledProduct.ProductId), Use = nameof(NullToEmpty))] - [MapProperty(nameof(BundledProductYaml.Lifecycle), nameof(BundledProduct.Lifecycle), Use = nameof(ParseLifecycle))] - public static partial BundledProduct ToBundledProduct(BundledProductYaml yaml); - - [MapProperty(nameof(BundledEntryYaml.Type), nameof(BundledEntry.Type), Use = nameof(ParseEntryTypeNullable))] - [MapProperty(nameof(BundledEntryYaml.Subtype), nameof(BundledEntry.Subtype), Use = nameof(ParseEntrySubtype))] - public static partial BundledEntry ToBundledEntry(BundledEntryYaml yaml); - - public static partial BundledFile ToBundledFile(BundledFileYaml yaml); - - /// - /// Converts a ChangelogEntry to a BundledEntry for embedding in bundles. - /// File property is not mapped; set it separately using a 'with' expression. - /// - [MapProperty(nameof(ChangelogEntry.Type), nameof(BundledEntry.Type), Use = nameof(EntryTypeToNullable))] - [MapperIgnoreTarget(nameof(BundledEntry.File))] - public static partial BundledEntry ToBundledEntry(ChangelogEntry entry); - - [MapProperty(nameof(ChangelogEntry.Type), nameof(ChangelogEntryYaml.Type), Use = nameof(EntryTypeToString))] - [MapProperty(nameof(ChangelogEntry.Subtype), nameof(ChangelogEntryYaml.Subtype), Use = nameof(EntrySubtypeToString))] - public static partial ChangelogEntryYaml ToYaml(ChangelogEntry entry); - - [MapProperty(nameof(ProductReference.ProductId), nameof(ProductInfoYaml.Product))] - [MapProperty(nameof(ProductReference.Lifecycle), nameof(ProductInfoYaml.Lifecycle), Use = nameof(LifecycleToString))] - public static partial ProductInfoYaml ToYaml(ProductReference product); - - public static partial BundleYaml ToYaml(Bundle bundle); - - [MapProperty(nameof(BundledProduct.ProductId), nameof(BundledProductYaml.Product))] - [MapProperty(nameof(BundledProduct.Lifecycle), nameof(BundledProductYaml.Lifecycle), Use = nameof(LifecycleToString))] - public static partial BundledProductYaml ToYaml(BundledProduct product); - - [MapProperty(nameof(BundledEntry.Type), nameof(BundledEntryYaml.Type), Use = nameof(EntryTypeNullableToString))] - [MapProperty(nameof(BundledEntry.Subtype), nameof(BundledEntryYaml.Subtype), Use = nameof(EntrySubtypeToString))] - public static partial BundledEntryYaml ToYaml(BundledEntry entry); - - public static partial BundledFileYaml ToYaml(BundledFile file); - - /// Converts nullable string to non-nullable, using empty string for null. - private static string NullToEmpty(string? value) => value ?? ""; - - private static ChangelogEntryType ParseEntryType(string? value) - { - if (string.IsNullOrEmpty(value)) - return ChangelogEntryType.Invalid; - - return ChangelogEntryTypeExtensions.TryParse(value, out var result, ignoreCase: true, allowMatchingMetadataAttribute: true) - ? result - : ChangelogEntryType.Invalid; - } - - private static ChangelogEntryType? ParseEntryTypeNullable(string? value) - { - if (string.IsNullOrEmpty(value)) - return null; - - return ChangelogEntryTypeExtensions.TryParse(value, out var result, ignoreCase: true, allowMatchingMetadataAttribute: true) - ? result - : null; - } - - private static ChangelogEntrySubtype? ParseEntrySubtype(string? value) - { - if (string.IsNullOrEmpty(value)) - return null; - - return ChangelogEntrySubtypeExtensions.TryParse(value, out var result, ignoreCase: true, allowMatchingMetadataAttribute: true) - ? result - : null; - } - - private static Lifecycle? ParseLifecycle(string? value) - { - if (string.IsNullOrEmpty(value)) - return null; - - return LifecycleExtensions.TryParse(value, out var result, ignoreCase: true, allowMatchingMetadataAttribute: true) - ? result - : null; - } - - private static string? EntryTypeToString(ChangelogEntryType value) => - value != ChangelogEntryType.Invalid ? value.ToStringFast(true) : null; - - private static ChangelogEntryType? EntryTypeToNullable(ChangelogEntryType value) => - value != ChangelogEntryType.Invalid ? value : null; - - private static string? EntryTypeNullableToString(ChangelogEntryType? value) => - value?.ToStringFast(true); - - private static string? EntrySubtypeToString(ChangelogEntrySubtype? value) => - value?.ToStringFast(true); - - private static string? LifecycleToString(Lifecycle? value) => - value?.ToStringFast(true); - - private static ChangelogEntryType BundledEntryTypeToEntryType(ChangelogEntryType? value) => - value ?? ChangelogEntryType.Invalid; -} diff --git a/src/services/Elastic.Changelog/Serialization/ChangelogYamlSerialization.cs b/src/services/Elastic.Changelog/Serialization/ChangelogYamlSerialization.cs deleted file mode 100644 index 9771a481c..000000000 --- a/src/services/Elastic.Changelog/Serialization/ChangelogYamlSerialization.cs +++ /dev/null @@ -1,97 +0,0 @@ -// 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.Documentation; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; - -namespace Elastic.Changelog.Serialization; - -/// -/// Centralized YAML serialization for changelog operations. -/// Provides static deserializers and serializers configured for different use cases. -/// -public static class ChangelogYamlSerialization -{ - private static readonly IDeserializer ConfigurationDeserializer = - new StaticDeserializerBuilder(new ChangelogYamlStaticContext()) - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .WithTypeConverter(new TypeEntryYamlConverter()) - .Build(); - - private static readonly IDeserializer YamlDeserializer = - new StaticDeserializerBuilder(new ChangelogYamlStaticContext()) - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .Build(); - - /// - /// Gets the raw YAML deserializer for changelog entry DTOs. - /// Used by bundling service for direct deserialization with error handling. - /// - public static IDeserializer GetEntryDeserializer() => YamlDeserializer; - - private static readonly ISerializer YamlSerializer = - new StaticSerializerBuilder(new ChangelogYamlStaticContext()) - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections) - .WithQuotingNecessaryStrings() - .DisableAliases() - .Build(); - - /// - /// Deserializes changelog configuration YAML content. - /// - internal static ChangelogConfigurationYaml DeserializeConfiguration(string yaml) => - ConfigurationDeserializer.Deserialize(yaml); - - /// - /// Deserializes a changelog entry YAML content to domain type. - /// - public static ChangelogEntry DeserializeEntry(string yaml) - { - var yamlDto = YamlDeserializer.Deserialize(yaml); - return ChangelogMapper.ToEntry(yamlDto); - } - - /// - /// Converts a raw YAML DTO to domain type. - /// Used by bundling service that handles deserialization separately for error handling. - /// - public static ChangelogEntry ConvertEntry(ChangelogEntryYaml yamlDto) => - ChangelogMapper.ToEntry(yamlDto); - - /// - /// Converts a BundledEntry to a ChangelogEntry. - /// Used when inline entry data is provided in bundles. - /// - public static ChangelogEntry ConvertBundledEntry(BundledEntry entry) => - ChangelogMapper.ToEntry(entry); - - /// - /// Deserializes bundled changelog data YAML content to domain type. - /// - public static Bundle DeserializeBundle(string yaml) - { - var yamlDto = YamlDeserializer.Deserialize(yaml); - return ChangelogMapper.ToBundle(yamlDto); - } - - /// - /// Serializes a changelog entry to YAML. - /// - public static string SerializeEntry(ChangelogEntry entry) - { - var yamlDto = ChangelogMapper.ToYaml(entry); - return YamlSerializer.Serialize(yamlDto); - } - - /// - /// Serializes bundled changelog data to YAML. - /// - public static string SerializeBundle(Bundle bundle) - { - var yamlDto = ChangelogMapper.ToYaml(bundle); - return YamlSerializer.Serialize(yamlDto); - } -} diff --git a/src/services/Elastic.Changelog/Serialization/ChangelogYamlStaticContext.cs b/src/services/Elastic.Changelog/Serialization/ChangelogYamlStaticContext.cs index ecead1a2f..83fef7ed4 100644 --- a/src/services/Elastic.Changelog/Serialization/ChangelogYamlStaticContext.cs +++ b/src/services/Elastic.Changelog/Serialization/ChangelogYamlStaticContext.cs @@ -7,10 +7,7 @@ namespace Elastic.Changelog.Serialization; [YamlStaticContext] -// YAML DTOs for changelog entries -[YamlSerializable(typeof(ChangelogEntryYaml))] -[YamlSerializable(typeof(ProductInfoYaml))] -// YAML DTOs for configuration +// YAML DTOs for CLI configuration (changelog.yml) [YamlSerializable(typeof(ChangelogConfigurationYaml))] [YamlSerializable(typeof(PivotConfigurationYaml))] [YamlSerializable(typeof(TypeEntryYaml))] @@ -22,9 +19,4 @@ namespace Elastic.Changelog.Serialization; [YamlSerializable(typeof(BundleConfigurationYaml))] [YamlSerializable(typeof(BundleProfileYaml))] [YamlSerializable(typeof(ExtractConfigurationYaml))] -// YAML DTOs for bundles -[YamlSerializable(typeof(BundleYaml))] -[YamlSerializable(typeof(BundledProductYaml))] -[YamlSerializable(typeof(BundledEntryYaml))] -[YamlSerializable(typeof(BundledFileYaml))] public partial class ChangelogYamlStaticContext; diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs new file mode 100644 index 000000000..7918f35d0 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs @@ -0,0 +1,527 @@ +// 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 System.IO.Abstractions.TestingHelpers; +using Elastic.Documentation.Configuration.ReleaseNotes; +using Elastic.Documentation.ReleaseNotes; +using FluentAssertions; + +namespace Elastic.Changelog.Tests.Changelogs.BundleLoading; + +public class BundleLoaderTests(ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + private readonly MockFileSystem _fileSystem = new(); + private readonly List _warnings = []; + + private BundleLoader CreateService() => new(_fileSystem); + + private void EmitWarning(string message) + { + _warnings.Add(message); + _output.WriteLine($"Warning: {message}"); + } + + #region LoadBundles Tests + + [Fact] + public void LoadBundles_WithValidBundles_ReturnsLoadedBundles() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // language=yaml + var bundleContent = + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Test feature + type: feature + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.yaml", bundleContent); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].Version.Should().Be("9.3.0"); + bundles[0].Repo.Should().Be("elasticsearch"); + bundles[0].Entries.Should().HaveCount(1); + bundles[0].Entries[0].Title.Should().Be("Test feature"); + bundles[0].Entries[0].Type.Should().Be(ChangelogEntryType.Feature); + _warnings.Should().BeEmpty(); + } + + [Fact] + public void LoadBundles_WithMultipleBundles_ReturnsAllBundles() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // language=yaml + var bundle1 = + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature in 9.3.0 + type: feature + """; + // language=yaml + var bundle2 = + """ + products: + - product: elasticsearch + target: 9.2.0 + entries: + - title: Feature in 9.2.0 + type: enhancement + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.yaml", bundle1); + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.2.0.yml", bundle2); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(2); + bundles.Select(b => b.Version).Should().Contain(["9.3.0", "9.2.0"]); + _warnings.Should().BeEmpty(); + } + + [Fact] + public void LoadBundles_WithInvalidYaml_EmitsWarningAndSkips() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // Invalid YAML - unclosed quote + var invalidYaml = "products: [unclosed"; + _fileSystem.File.WriteAllText($"{bundlesFolder}/invalid.yaml", invalidYaml); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().BeEmpty(); + _warnings.Should().ContainSingle(); + _warnings[0].Should().Contain("Failed to parse changelog bundle 'invalid.yaml'"); + } + + [Fact] + public void LoadBundles_WithNoProducts_UsesFilenameAsVersion() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // language=yaml + var bundleContent = + """ + products: [] + entries: + - title: Test entry + type: feature + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/2025-01-28.yaml", bundleContent); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].Version.Should().Be("2025-01-28"); + bundles[0].Repo.Should().Be("elastic"); + } + + #endregion + + #region ResolveEntries Tests + + [Fact] + public void ResolveEntries_WithInlineEntries_ReturnsEntries() + { + // Arrange + var service = CreateService(); + var bundle = new Bundle + { + Products = + [ + new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" } + ], + Entries = + [ + new BundledEntry { Title = "Test feature", Type = ChangelogEntryType.Feature }, + new BundledEntry { Title = "Test fix", Type = ChangelogEntryType.BugFix } + ] + }; + + // Act + var entries = service.ResolveEntries(bundle, "/changelog", EmitWarning); + + // Assert + entries.Should().HaveCount(2); + entries[0].Title.Should().Be("Test feature"); + entries[1].Title.Should().Be("Test fix"); + _warnings.Should().BeEmpty(); + } + + [Fact] + public void ResolveEntries_WithFileReferences_LoadsFromFiles() + { + // Arrange + var changelogDir = "/docs/changelog"; + _fileSystem.Directory.CreateDirectory($"{changelogDir}/entries"); + + // language=yaml + var entryContent = + """ + title: Feature from file + type: feature + pr: https://github.com/elastic/elasticsearch/pull/100 + description: A feature loaded from a file + """; + _fileSystem.File.WriteAllText($"{changelogDir}/entries/feature.yaml", entryContent); + + var service = CreateService(); + var bundle = new Bundle + { + Products = + [ + new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" } + ], + Entries = + [ + new BundledEntry { File = new BundledFile { Name = "entries/feature.yaml", Checksum = "sha1" } } + ] + }; + + // Act + var entries = service.ResolveEntries(bundle, changelogDir, EmitWarning); + + // Assert + entries.Should().HaveCount(1); + entries[0].Title.Should().Be("Feature from file"); + entries[0].Description.Should().Be("A feature loaded from a file"); + _warnings.Should().BeEmpty(); + } + + [Fact] + public void ResolveEntries_WithMissingFileReference_EmitsWarning() + { + // Arrange + var changelogDir = "/docs/changelog"; + _fileSystem.Directory.CreateDirectory(changelogDir); + + var service = CreateService(); + var bundle = new Bundle + { + Products = + [ + new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" } + ], + Entries = + [ + new BundledEntry { File = new BundledFile { Name = "nonexistent.yaml", Checksum = "sha1" } } + ] + }; + + // Act + var entries = service.ResolveEntries(bundle, changelogDir, EmitWarning); + + // Assert + entries.Should().BeEmpty(); + _warnings.Should().ContainSingle(); + _warnings[0].Should().Contain("not found"); + } + + [Fact] + public void ResolveEntries_WithVersionField_NormalizesToTarget() + { + // Arrange + var changelogDir = "/docs/changelog"; + _fileSystem.Directory.CreateDirectory(changelogDir); + + // Using legacy 'version:' field instead of 'target:' + // language=yaml + var entryContent = + """ + title: Legacy entry + type: feature + products: + - product: elasticsearch + version: 9.3.0 + """; + _fileSystem.File.WriteAllText($"{changelogDir}/legacy.yaml", entryContent); + + var service = CreateService(); + var bundle = new Bundle + { + Products = [new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" }], + Entries = [new BundledEntry { File = new BundledFile { Name = "legacy.yaml", Checksum = "sha1" } }] + }; + + // Act + var entries = service.ResolveEntries(bundle, changelogDir, EmitWarning); + + // Assert + entries.Should().HaveCount(1); + entries[0].Title.Should().Be("Legacy entry"); + _warnings.Should().BeEmpty(); + } + + #endregion + + #region FilterEntries Tests + + [Fact] + public void FilterEntries_WithNoFilters_ReturnsAllEntries() + { + // Arrange + var service = CreateService(); + var entries = new List + { + new() { Title = "Entry 1", Type = ChangelogEntryType.Feature }, + new() { Title = "Entry 2", Type = ChangelogEntryType.BugFix } + }; + + // Act + var filtered = service.FilterEntries(entries, null); + + // Assert + filtered.Should().HaveCount(2); + } + + [Fact] + public void FilterEntries_WithPublishBlocker_HidesBlockedTypes() + { + // Arrange + var service = CreateService(); + var entries = new List + { + new() { Title = "Feature", Type = ChangelogEntryType.Feature }, + new() { Title = "Regression", Type = ChangelogEntryType.Regression }, + new() { Title = "Bug fix", Type = ChangelogEntryType.BugFix } + }; + + var publishBlocker = new PublishBlocker + { + Types = ["regression"] + }; + + // Act + var filtered = service.FilterEntries(entries, publishBlocker); + + // Assert + filtered.Should().HaveCount(2); + filtered.Select(e => e.Type).Should().NotContain(ChangelogEntryType.Regression); + } + + [Fact] + public void FilterEntries_WithPublishBlocker_HidesBlockedAreas() + { + // Arrange + var service = CreateService(); + var entries = new List + { + new() { Title = "Public feature", Type = ChangelogEntryType.Feature, Areas = ["Search"] }, + new() { Title = "Internal feature", Type = ChangelogEntryType.Feature, Areas = ["Internal"] }, + new() { Title = "Mixed feature", Type = ChangelogEntryType.Feature, Areas = ["Search", "Internal"] } + }; + + var publishBlocker = new PublishBlocker + { + Areas = ["Internal"] + }; + + // Act + var filtered = service.FilterEntries(entries, publishBlocker); + + // Assert + filtered.Should().HaveCount(1); + filtered[0].Title.Should().Be("Public feature"); + } + + [Fact] + public void FilterEntries_WithPublishBlocker_CombinesTypeAndAreaBlocking() + { + // Arrange + var service = CreateService(); + var entries = new List + { + new() { Title = "Visible", Type = ChangelogEntryType.Feature, Areas = ["Search"] }, + new() { Title = "Hidden by type", Type = ChangelogEntryType.Regression, Areas = ["Search"] }, + new() { Title = "Hidden by area", Type = ChangelogEntryType.Feature, Areas = ["Internal"] } + }; + + var publishBlocker = new PublishBlocker + { + Types = ["regression"], + Areas = ["Internal"] + }; + + // Act + var filtered = service.FilterEntries(entries, publishBlocker); + + // Assert + filtered.Should().HaveCount(1); + filtered[0].Title.Should().Be("Visible"); + } + + #endregion + + #region MergeBundlesByTarget Tests + + [Fact] + public void MergeBundlesByTarget_WithSingleBundle_ReturnsSameBundle() + { + // Arrange + var service = CreateService(); + var bundles = new List + { + new("9.3.0", "elasticsearch", new Bundle(), "/path/to/bundle.yaml", + [new ChangelogEntry { Title = "Entry 1", Type = ChangelogEntryType.Feature }]) + }; + + // Act + var merged = service.MergeBundlesByTarget(bundles); + + // Assert + merged.Should().HaveCount(1); + merged[0].Should().BeSameAs(bundles[0]); + } + + [Fact] + public void MergeBundlesByTarget_WithDifferentVersions_KeepsSeparate() + { + // Arrange + var service = CreateService(); + var bundles = new List + { + new("9.3.0", "elasticsearch", new Bundle(), "/path/to/9.3.0.yaml", + [new ChangelogEntry { Title = "Entry 9.3.0", Type = ChangelogEntryType.Feature }]), + new("9.2.0", "elasticsearch", new Bundle(), "/path/to/9.2.0.yaml", + [new ChangelogEntry { Title = "Entry 9.2.0", Type = ChangelogEntryType.Feature }]) + }; + + // Act + var merged = service.MergeBundlesByTarget(bundles); + + // Assert + merged.Should().HaveCount(2); + } + + [Fact] + public void MergeBundlesByTarget_WithSameVersion_MergesEntries() + { + // Arrange + var service = CreateService(); + var bundles = new List + { + new("9.3.0", "elasticsearch", new Bundle(), "/path/to/es.yaml", + [new ChangelogEntry { Title = "ES Entry", Type = ChangelogEntryType.Feature }]), + new("9.3.0", "kibana", new Bundle(), "/path/to/kibana.yaml", + [new ChangelogEntry { Title = "Kibana Entry", Type = ChangelogEntryType.Feature }]) + }; + + // Act + var merged = service.MergeBundlesByTarget(bundles); + + // Assert + merged.Should().HaveCount(1); + merged[0].Version.Should().Be("9.3.0"); + merged[0].Repo.Should().Be("elasticsearch+kibana"); + merged[0].Entries.Should().HaveCount(2); + merged[0].Entries.Select(e => e.Title).Should().Contain(["ES Entry", "Kibana Entry"]); + } + + [Fact] + public void MergeBundlesByTarget_PreservesSortOrder() + { + // Arrange + var service = CreateService(); + var bundles = new List + { + new("9.2.0", "elasticsearch", new Bundle(), "/path/to/9.2.0.yaml", []), + new("9.3.0", "elasticsearch", new Bundle(), "/path/to/9.3.0.yaml", []), + new("9.1.0", "elasticsearch", new Bundle(), "/path/to/9.1.0.yaml", []) + }; + + // Act + var merged = service.MergeBundlesByTarget(bundles); + + // Assert + merged.Should().HaveCount(3); + merged[0].Version.Should().Be("9.3.0"); + merged[1].Version.Should().Be("9.2.0"); + merged[2].Version.Should().Be("9.1.0"); + } + + [Fact] + public void MergeBundlesByTarget_WithDateVersions_SortsCorrectly() + { + // Arrange - Date-based versions for serverless releases + var service = CreateService(); + var bundles = new List + { + new("2025-01-15", "cloud-serverless", new Bundle(), "/path/to/jan15.yaml", []), + new("2025-01-28", "cloud-serverless", new Bundle(), "/path/to/jan28.yaml", []), + new("2025-01-01", "cloud-serverless", new Bundle(), "/path/to/jan01.yaml", []) + }; + + // Act + var merged = service.MergeBundlesByTarget(bundles); + + // Assert + merged.Should().HaveCount(3); + merged[0].Version.Should().Be("2025-01-28"); + merged[1].Version.Should().Be("2025-01-15"); + merged[2].Version.Should().Be("2025-01-01"); + } + + #endregion + + #region EntriesByType Tests + + [Fact] + public void LoadedBundle_EntriesByType_GroupsCorrectly() + { + // Arrange + var entries = new List + { + new() { Title = "Feature 1", Type = ChangelogEntryType.Feature }, + new() { Title = "Feature 2", Type = ChangelogEntryType.Feature }, + new() { Title = "Bug fix", Type = ChangelogEntryType.BugFix }, + new() { Title = "Breaking change", Type = ChangelogEntryType.BreakingChange } + }; + var bundle = new LoadedBundle("9.3.0", "elasticsearch", new Bundle(), "/path/to/bundle.yaml", entries); + + // Act + var byType = bundle.EntriesByType; + + // Assert + byType.Should().HaveCount(3); + byType[ChangelogEntryType.Feature].Should().HaveCount(2); + byType[ChangelogEntryType.BugFix].Should().HaveCount(1); + byType[ChangelogEntryType.BreakingChange].Should().HaveCount(1); + } + + #endregion +} diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogTextUtilitiesTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogTextUtilitiesTests.cs new file mode 100644 index 000000000..474ef6bf8 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogTextUtilitiesTests.cs @@ -0,0 +1,121 @@ +// 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.Documentation.ReleaseNotes; +using FluentAssertions; + +namespace Elastic.Documentation.Configuration.Tests.ReleaseNotes; + +/// +/// Unit tests for ChangelogTextUtilities. +/// These tests verify the text processing utilities for changelog generation. +/// +public class ChangelogTextUtilitiesTests +{ + [Theory] + [InlineData("hello world", "Hello world.")] + [InlineData("Hello world", "Hello world.")] + [InlineData("Hello world.", "Hello world.")] + [InlineData("a", "A.")] + [InlineData("", "")] + [InlineData(null, "")] + public void Beautify_CapitalizesAndAddsPeriod(string? input, string expected) + { + var result = ChangelogTextUtilities.Beautify(input ?? ""); + result.Should().Be(expected); + } + + [Theory] + [InlineData("9.3.0", "9.3.0")] + [InlineData("Version 9.3.0", "version-9.3.0")] + [InlineData("Version 9.3.0-beta1", "version-9.3.0-beta1")] + public void TitleToSlug_ConvertsToSlugFormat(string input, string expected) + { + var result = ChangelogTextUtilities.TitleToSlug(input); + result.Should().Be(expected); + } + + [Theory] + [InlineData("search-api", "Search api")] + [InlineData("ingest-pipeline", "Ingest pipeline")] + [InlineData("api", "Api")] + public void FormatAreaHeader_CapitalizesAndReplacesHyphens(string input, string expected) + { + var result = ChangelogTextUtilities.FormatAreaHeader(input); + result.Should().Be(expected); + } + + [Theory] + [InlineData("[Inference API] Add new endpoint", "Add new endpoint")] + [InlineData("[ES]: Fix bug", "Fix bug")] + [InlineData("[Test] Title", "Title")] + [InlineData("No bracket prefix", "No bracket prefix")] + [InlineData("[Unclosed bracket", "[Unclosed bracket")] + public void StripSquareBracketPrefix_RemovesPrefix(string input, string expected) + { + var result = ChangelogTextUtilities.StripSquareBracketPrefix(input); + result.Should().Be(expected); + } + + [Theory] + [InlineData("https://github.com/elastic/elasticsearch/pull/123", 123)] + [InlineData("elastic/elasticsearch#456", 456)] + [InlineData("123", null)] // No default owner/repo + public void ExtractPrNumber_ExtractsNumber(string input, int? expected) + { + var result = ChangelogTextUtilities.ExtractPrNumber(input); + result.Should().Be(expected); + } + + [Fact] + public void ExtractPrNumber_WithDefaultOwnerRepo_ExtractsNumber() + { + var result = ChangelogTextUtilities.ExtractPrNumber("123", "elastic", "elasticsearch"); + result.Should().Be(123); + } + + [Theory] + [InlineData("v1.0.0", "ga")] + [InlineData("v1.0.0-beta1", "beta")] + [InlineData("v1.0.0-preview.1", "preview")] + [InlineData("1.0.0-alpha1", "preview")] + [InlineData("1.0.0-rc1", "beta")] + [InlineData("1.0.0", "ga")] + public void InferLifecycleFromVersion_InfersCorrectly(string tagName, string expected) + { + var result = ChangelogTextUtilities.InferLifecycleFromVersion(tagName); + result.Should().Be(expected); + } + + [Theory] + [InlineData("v1.0.0", "1.0.0")] + [InlineData("v1.0.0-beta1", "1.0.0")] + [InlineData("1.2.3-preview.1", "1.2.3")] + [InlineData("9.3.0", "9.3.0")] + public void ExtractBaseVersion_ExtractsVersion(string tagName, string expected) + { + var result = ChangelogTextUtilities.ExtractBaseVersion(tagName); + result.Should().Be(expected); + } + + [Theory] + [InlineData("elastic/elasticsearch", "elastic", "elasticsearch")] + [InlineData("elasticsearch", null, "elasticsearch")] + public void ParseRepository_ParsesCorrectly(string input, string? expectedOwner, string expectedRepo) + { + var (owner, repo) = ChangelogTextUtilities.ParseRepository(input); + owner.Should().Be(expectedOwner); + repo.Should().Be(expectedRepo); + } + + [Theory] + [InlineData("Add new feature to API", "add-new-feature-to-api")] + [InlineData("Fix bug in the search API endpoint handler", "fix-bug-in-the-search-api")] // Takes first 6 words by default + [InlineData("", "untitled")] + public void GenerateSlug_GeneratesSlug(string input, string expected) + { + var result = ChangelogTextUtilities.GenerateSlug(input); + result.Should().Be(expected); + } +} diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/LoadPublishBlockerTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/LoadPublishBlockerTests.cs new file mode 100644 index 000000000..b9e97053e --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/LoadPublishBlockerTests.cs @@ -0,0 +1,167 @@ +// 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 System.IO.Abstractions.TestingHelpers; +using Elastic.Documentation.Configuration.ReleaseNotes; +using FluentAssertions; + +namespace Elastic.Documentation.Configuration.Tests.ReleaseNotes; + +/// +/// Unit tests for ReleaseNotesSerialization.LoadPublishBlocker. +/// These tests verify the publish blocker loading functionality that +/// was consolidated from the old PublishBlockerLoader. +/// +public class LoadPublishBlockerTests +{ + private readonly MockFileSystem _fileSystem = new(); + + [Fact] + public void LoadPublishBlocker_ReturnsNull_WhenFileDoesNotExist() + { + var result = ReleaseNotesSerialization.LoadPublishBlocker(_fileSystem, "/nonexistent/changelog.yml"); + + result.Should().BeNull(); + } + + [Fact] + public void LoadPublishBlocker_ReturnsNull_WhenFileIsEmpty() + { + _fileSystem.AddFile("/docs/changelog.yml", new MockFileData("")); + + var result = ReleaseNotesSerialization.LoadPublishBlocker(_fileSystem, "/docs/changelog.yml"); + + result.Should().BeNull(); + } + + [Fact] + public void LoadPublishBlocker_ReturnsNull_WhenNoBlockSection() + { + // language=yaml + var yaml = """ + project: test + other: value + """; + _fileSystem.AddFile("/docs/changelog.yml", new MockFileData(yaml)); + + var result = ReleaseNotesSerialization.LoadPublishBlocker(_fileSystem, "/docs/changelog.yml"); + + result.Should().BeNull(); + } + + [Fact] + public void LoadPublishBlocker_ParsesTypesOnly() + { + // language=yaml + var yaml = """ + block: + publish: + types: + - deprecation + - known-issue + """; + _fileSystem.AddFile("/docs/changelog.yml", new MockFileData(yaml)); + + var result = ReleaseNotesSerialization.LoadPublishBlocker(_fileSystem, "/docs/changelog.yml"); + + result.Should().NotBeNull(); + result!.Types.Should().HaveCount(2) + .And.Contain("deprecation") + .And.Contain("known-issue"); + result.Areas.Should().BeNull(); + result.HasBlockingRules.Should().BeTrue(); + } + + [Fact] + public void LoadPublishBlocker_ParsesAreasOnly() + { + // language=yaml + var yaml = """ + block: + publish: + areas: + - Internal + - Experimental + """; + _fileSystem.AddFile("/docs/changelog.yml", new MockFileData(yaml)); + + var result = ReleaseNotesSerialization.LoadPublishBlocker(_fileSystem, "/docs/changelog.yml"); + + result.Should().NotBeNull(); + result!.Areas.Should().HaveCount(2) + .And.Contain("Internal") + .And.Contain("Experimental"); + result.Types.Should().BeNull(); + result.HasBlockingRules.Should().BeTrue(); + } + + [Fact] + public void LoadPublishBlocker_ParsesTypesAndAreas() + { + // language=yaml + var yaml = """ + block: + publish: + types: + - deprecation + areas: + - Internal + """; + _fileSystem.AddFile("/docs/changelog.yml", new MockFileData(yaml)); + + var result = ReleaseNotesSerialization.LoadPublishBlocker(_fileSystem, "/docs/changelog.yml"); + + result.Should().NotBeNull(); + result!.Types.Should().HaveCount(1).And.Contain("deprecation"); + result.Areas.Should().HaveCount(1).And.Contain("Internal"); + result.HasBlockingRules.Should().BeTrue(); + } + + [Fact] + public void LoadPublishBlocker_ReturnsNull_WhenPublishHasEmptyTypesAndAreas() + { + // language=yaml + var yaml = """ + block: + publish: + types: [] + areas: [] + """; + _fileSystem.AddFile("/docs/changelog.yml", new MockFileData(yaml)); + + var result = ReleaseNotesSerialization.LoadPublishBlocker(_fileSystem, "/docs/changelog.yml"); + + result.Should().BeNull(); + } + + [Fact] + public void LoadPublishBlocker_IgnoresOtherProperties() + { + // language=yaml + var yaml = """ + project: test-project + pivot: + types: + feature: labels + lifecycles: + - ga + - beta + block: + create: some-label + publish: + types: + - deprecation + product: + elasticsearch: + create: es-label + """; + _fileSystem.AddFile("/docs/changelog.yml", new MockFileData(yaml)); + + var result = ReleaseNotesSerialization.LoadPublishBlocker(_fileSystem, "/docs/changelog.yml"); + + result.Should().NotBeNull(); + result!.Types.Should().HaveCount(1).And.Contain("deprecation"); + result.Areas.Should().BeNull(); + } +} diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/PublishBlockerExtensionsTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/PublishBlockerExtensionsTests.cs new file mode 100644 index 000000000..7d6c0cb73 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/PublishBlockerExtensionsTests.cs @@ -0,0 +1,141 @@ +// 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.Documentation.ReleaseNotes; +using FluentAssertions; + +namespace Elastic.Documentation.Configuration.Tests.ReleaseNotes; + +/// +/// Unit tests for PublishBlockerExtensions.ShouldBlock. +/// These tests verify the blocking logic for changelog entries. +/// +public class PublishBlockerExtensionsTests +{ + [Fact] + public void ShouldBlock_ReturnsFalse_WhenNoBlockingRules() + { + // Arrange + var blocker = new PublishBlocker(); + var entry = new ChangelogEntry { Title = "Test", Type = ChangelogEntryType.Feature }; + + // Act + var result = blocker.ShouldBlock(entry); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ShouldBlock_ReturnsTrue_WhenTypeIsBlocked() + { + // Arrange + var blocker = new PublishBlocker { Types = ["regression", "known-issue"] }; + var entry = new ChangelogEntry { Title = "Test", Type = ChangelogEntryType.Regression }; + + // Act + var result = blocker.ShouldBlock(entry); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void ShouldBlock_ReturnsFalse_WhenTypeIsNotBlocked() + { + // Arrange + var blocker = new PublishBlocker { Types = ["regression", "known-issue"] }; + var entry = new ChangelogEntry { Title = "Test", Type = ChangelogEntryType.Feature }; + + // Act + var result = blocker.ShouldBlock(entry); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ShouldBlock_ReturnsTrue_WhenAreaIsBlocked() + { + // Arrange + var blocker = new PublishBlocker { Areas = ["Internal", "Experimental"] }; + var entry = new ChangelogEntry { Title = "Test", Type = ChangelogEntryType.Feature, Areas = ["Internal"] }; + + // Act + var result = blocker.ShouldBlock(entry); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void ShouldBlock_ReturnsFalse_WhenAreaIsNotBlocked() + { + // Arrange + var blocker = new PublishBlocker { Areas = ["Internal", "Experimental"] }; + var entry = new ChangelogEntry { Title = "Test", Type = ChangelogEntryType.Feature, Areas = ["Search"] }; + + // Act + var result = blocker.ShouldBlock(entry); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ShouldBlock_ReturnsTrue_WhenAnyAreaIsBlocked() + { + // Arrange + var blocker = new PublishBlocker { Areas = ["Internal"] }; + var entry = new ChangelogEntry { Title = "Test", Type = ChangelogEntryType.Feature, Areas = ["Search", "Internal"] }; + + // Act + var result = blocker.ShouldBlock(entry); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void ShouldBlock_IsCaseInsensitive_ForTypes() + { + // Arrange + var blocker = new PublishBlocker { Types = ["REGRESSION"] }; + var entry = new ChangelogEntry { Title = "Test", Type = ChangelogEntryType.Regression }; + + // Act + var result = blocker.ShouldBlock(entry); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void ShouldBlock_IsCaseInsensitive_ForAreas() + { + // Arrange + var blocker = new PublishBlocker { Areas = ["INTERNAL"] }; + var entry = new ChangelogEntry { Title = "Test", Type = ChangelogEntryType.Feature, Areas = ["internal"] }; + + // Act + var result = blocker.ShouldBlock(entry); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void ShouldBlock_ReturnsFalse_WhenEntryHasNoAreas() + { + // Arrange + var blocker = new PublishBlocker { Areas = ["Internal"] }; + var entry = new ChangelogEntry { Title = "Test", Type = ChangelogEntryType.Feature }; + + // Act + var result = blocker.ShouldBlock(entry); + + // Assert + result.Should().BeFalse(); + } +} diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs new file mode 100644 index 000000000..c680ef3f2 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs @@ -0,0 +1,555 @@ +// 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 System.IO.Abstractions.TestingHelpers; +using Elastic.Markdown.Myst.Directives.Changelog; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.Directives; + +public class ChangelogBasicTests : DirectiveTest +{ + public ChangelogBasicTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => + // Create the default bundles folder with a test bundle + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + lifecycle: ga + entries: + - title: Add new feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Search + pr: "123456" + description: This is a great new feature. + - title: Fix important bug + type: bug-fix + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Indexing + pr: "123457" + """)); + + [Fact] + public void ParsesChangelogBlock() => Block.Should().NotBeNull(); + + [Fact] + public void SetsCorrectDirectiveType() => Block!.Directive.Should().Be("changelog"); + + [Fact] + public void FindsBundlesFolder() => Block!.Found.Should().BeTrue(); + + [Fact] + public void SetsCorrectBundlesFolderPath() => Block!.BundlesFolderPath.Should().EndWith("changelog/bundles".Replace('/', Path.DirectorySeparatorChar)); + + [Fact] + public void LoadsBundles() => Block!.LoadedBundles.Should().HaveCount(1); + + [Fact] + public void RendersMarkdownContent() + { + Html.Should().Contain("9.3.0"); + Html.Should().Contain("Features and enhancements"); + Html.Should().Contain("Add new feature"); + Html.Should().Contain("Fixes"); + Html.Should().Contain("Fix important bug"); + } +} + +public class ChangelogMultipleBundlesTests : DirectiveTest +{ + public ChangelogMultipleBundlesTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + // Create multiple bundles with different versions + FileSystem.AddFile("docs/changelog/bundles/9.2.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.2.0 + entries: + - title: Feature in 9.2.0 + type: feature + products: + - product: elasticsearch + target: 9.2.0 + pr: "111111" + """)); + + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature in 9.3.0 + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "222222" + """)); + + FileSystem.AddFile("docs/changelog/bundles/9.10.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.10.0 + entries: + - title: Feature in 9.10.0 + type: feature + products: + - product: elasticsearch + target: 9.10.0 + pr: "333333" + """)); + } + + [Fact] + public void LoadsBundles() => Block!.LoadedBundles.Should().HaveCount(3); + + [Fact] + public void RendersInSemverOrder() + { + // Should be sorted by semver descending: 9.10.0 > 9.3.0 > 9.2.0 + var idx910 = Html.IndexOf("9.10.0", StringComparison.Ordinal); + var idx93 = Html.IndexOf("9.3.0", StringComparison.Ordinal); + var idx92 = Html.IndexOf("9.2.0", StringComparison.Ordinal); + + idx910.Should().BeLessThan(idx93, "9.10.0 should appear before 9.3.0"); + idx93.Should().BeLessThan(idx92, "9.3.0 should appear before 9.2.0"); + } + + [Fact] + public void RendersAllVersions() + { + Html.Should().Contain("9.10.0"); + Html.Should().Contain("9.3.0"); + Html.Should().Contain("9.2.0"); + } +} + +public class ChangelogCustomPathTests : DirectiveTest +{ + public ChangelogCustomPathTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} release-notes/bundles + ::: + """) => FileSystem.AddFile("docs/release-notes/bundles/1.0.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: my-product + target: 1.0.0 + entries: + - title: First release + type: feature + products: + - product: my-product + target: 1.0.0 + pr: "1" + """)); + + [Fact] + public void FindsBundlesFolder() => Block!.Found.Should().BeTrue(); + + [Fact] + public void SetsCorrectBundlesFolderPath() => Block!.BundlesFolderPath.Should().EndWith("release-notes/bundles".Replace('/', Path.DirectorySeparatorChar)); + + [Fact] + public void RendersContent() + { + Html.Should().Contain("1.0.0"); + Html.Should().Contain("First release"); + } +} + +public class ChangelogNotFoundTests(ITestOutputHelper output) : DirectiveTest(output, + // language=markdown + """ + :::{changelog} missing-bundles + ::: + """) +{ + [Fact] + public void ReportsFolderNotFound() => Block!.Found.Should().BeFalse(); + + [Fact] + public void EmitsErrorForMissingFolder() + { + Collector.Diagnostics.Should().NotBeNullOrEmpty(); + Collector.Diagnostics.Should().OnlyContain(d => d.Message.Contains("does not exist")); + } +} + +public class ChangelogDefaultPathMissingTests(ITestOutputHelper output) : DirectiveTest(output, + // language=markdown + """ + :::{changelog} + ::: + """) +{ + [Fact] + public void EmitsErrorForMissingDefaultFolder() + { + // No bundles folder created, so it should emit an error + Collector.Diagnostics.Should().NotBeNullOrEmpty(); + Collector.Diagnostics.Should().OnlyContain(d => d.Message.Contains("does not exist")); + } +} + +/// +/// Tests for breaking changes rendering. +/// Breaking changes should always render on the page when using :type: all. +/// +public class ChangelogWithBreakingChangesTests : DirectiveTest +{ + public ChangelogWithBreakingChangesTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: all + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Breaking change in API + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: The API has changed significantly. + impact: Users must update their code. + action: Follow the migration guide. + pr: "222222" + """)); + + [Fact] + public void RendersBreakingChangesSection() + { + Html.Should().Contain("Breaking changes"); + Html.Should().Contain("Breaking change in API"); + } + + [Fact] + public void RendersImpactAndAction() + { + Html.Should().Contain("Impact"); + Html.Should().Contain("Users must update their code"); + Html.Should().Contain("Action"); + Html.Should().Contain("Follow the migration guide"); + } +} + +/// +/// Tests for deprecations rendering. +/// Deprecations should always render on the page when using :type: all. +/// +public class ChangelogWithDeprecationsTests : DirectiveTest +{ + public ChangelogWithDeprecationsTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: all + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Deprecated old API + type: deprecation + products: + - product: elasticsearch + target: 9.3.0 + description: The old API is deprecated. + impact: The API will be removed in a future version. + action: Use the new API instead. + pr: "333333" + """)); + + [Fact] + public void RendersDeprecationsSection() + { + Html.Should().Contain("Deprecations"); + Html.Should().Contain("Deprecated old API"); + } +} + +public class ChangelogEmptyBundleTests : DirectiveTest +{ + public ChangelogEmptyBundleTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: [] + """)); + + [Fact] + public void HandlesEmptyBundle() + { + Html.Should().Contain("No new features, enhancements, or fixes"); + } +} + +public class ChangelogEmptyFolderTests : DirectiveTest +{ + public ChangelogEmptyFolderTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => + // Create the folder but don't add any YAML files + FileSystem.AddDirectory("docs/changelog/bundles"); + + [Fact] + public void ReportsFolderEmpty() => Block!.Found.Should().BeFalse(); + + [Fact] + public void EmitsErrorForEmptyFolder() + { + Collector.Diagnostics.Should().NotBeNullOrEmpty(); + Collector.Diagnostics.Should().OnlyContain(d => d.Message.Contains("contains no YAML files")); + } +} + +public class ChangelogAbsolutePathTests : DirectiveTest +{ + public ChangelogAbsolutePathTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} /release-notes/bundles + ::: + """) => FileSystem.AddFile("docs/release-notes/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Test feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "444444" + """)); + + [Fact] + public void FindsBundlesFolderWithAbsolutePath() => Block!.Found.Should().BeTrue(); + + [Fact] + public void SetsCorrectBundlesFolderPath() => Block!.BundlesFolderPath.Should().Contain("release-notes"); +} + +/// +/// Tests the section order - critical types (breaking changes, security, known issues, deprecations) +/// should appear BEFORE features/fixes when using :type: all. +/// +public class ChangelogSectionOrderTests : DirectiveTest +{ + public ChangelogSectionOrderTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: all + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: New feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Security fix + type: security + products: + - product: elasticsearch + target: 9.3.0 + pr: "222222" + - title: Breaking API change + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: API changed. + impact: Users must update. + action: Follow guide. + pr: "333333" + - title: Known issue + type: known-issue + products: + - product: elasticsearch + target: 9.3.0 + description: Issue exists. + impact: Some impact. + action: Workaround available. + pr: "444444" + - title: Deprecated feature + type: deprecation + products: + - product: elasticsearch + target: 9.3.0 + description: Feature deprecated. + impact: Will be removed. + action: Use new feature. + pr: "555555" + - title: Bug fix + type: bug-fix + products: + - product: elasticsearch + target: 9.3.0 + pr: "666666" + """)); + + [Fact] + public void BreakingChangesAppearsFirst() + { + var breakingIdx = Html.IndexOf("Breaking changes", StringComparison.Ordinal); + var featuresIdx = Html.IndexOf("Features and enhancements", StringComparison.Ordinal); + var fixesIdx = Html.IndexOf(">Fixes<", StringComparison.Ordinal); + + breakingIdx.Should().BeLessThan(featuresIdx, "Breaking changes should appear before Features"); + breakingIdx.Should().BeLessThan(fixesIdx, "Breaking changes should appear before Fixes"); + } + + [Fact] + public void SecurityAppearsBeforeFeatures() + { + var securityIdx = Html.IndexOf(">Security<", StringComparison.Ordinal); + var featuresIdx = Html.IndexOf("Features and enhancements", StringComparison.Ordinal); + + securityIdx.Should().BeLessThan(featuresIdx, "Security should appear before Features"); + } + + [Fact] + public void KnownIssuesAppearsBeforeFeatures() + { + var knownIssuesIdx = Html.IndexOf("Known issues", StringComparison.Ordinal); + var featuresIdx = Html.IndexOf("Features and enhancements", StringComparison.Ordinal); + + knownIssuesIdx.Should().BeLessThan(featuresIdx, "Known issues should appear before Features"); + } + + [Fact] + public void DeprecationsAppearsBeforeFeatures() + { + var deprecationsIdx = Html.IndexOf("Deprecations", StringComparison.Ordinal); + var featuresIdx = Html.IndexOf("Features and enhancements", StringComparison.Ordinal); + + deprecationsIdx.Should().BeLessThan(featuresIdx, "Deprecations should appear before Features"); + } +} + +/// +/// Tests header levels: ## (h2) for versions, ### (h3) for sections. +/// +public class ChangelogHeaderLevelsTests : DirectiveTest +{ + public ChangelogHeaderLevelsTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: New feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Bug fix + type: bug-fix + products: + - product: elasticsearch + target: 9.3.0 + pr: "222222" + """)); + + [Fact] + public void VersionHeaderIsH2() + { + // Version should be h2 + Html.Should().Contain(" +{ + public ChangelogConfigLoadAutoDiscoverTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + // Create bundles with entries of different types + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Regular feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Deprecation notice + type: deprecation + products: + - product: elasticsearch + target: 9.3.0 + description: This API is deprecated. + impact: Users should migrate. + action: Use the new API. + pr: "222222" + - title: Known issue + type: known-issue + products: + - product: elasticsearch + target: 9.3.0 + description: There is a known issue. + impact: Some users may be affected. + pr: "333333" + """)); + + // Add changelog config with publish blockers + FileSystem.AddFile("docs/changelog.yml", new MockFileData( + // language=yaml + """ + block: + publish: + types: + - deprecation + - known-issue + """)); + } + + [Fact] + public void LoadsPublishBlockerFromConfig() => Block!.PublishBlocker.Should().NotBeNull(); + + [Fact] + public void PublishBlockerHasCorrectTypes() + { + Block!.PublishBlocker!.Types.Should().NotBeNull(); + Block!.PublishBlocker!.Types.Should().Contain("deprecation"); + Block!.PublishBlocker!.Types.Should().Contain("known-issue"); + } + + [Fact] + public void FilteredEntriesExcludeBlockedTypes() + { + // Deprecation and known-issue entries should be filtered out + Html.Should().Contain("Regular feature"); + Html.Should().NotContain("Deprecation notice"); + Html.Should().NotContain("Known issue"); + } + + [Fact] + public void RendersFeaturesSection() => Html.Should().Contain("Features and enhancements"); + + [Fact] + public void DoesNotRenderDeprecationsSection() => Html.Should().NotContain("Deprecations"); + + [Fact] + public void DoesNotRenderKnownIssuesSection() => Html.Should().NotContain("Known issues"); +} + +public class ChangelogConfigLoadExplicitPathTests : DirectiveTest +{ + public ChangelogConfigLoadExplicitPathTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :config: custom/path/my-changelog.yml + ::: + """) + { + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Regular feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Internal docs + type: docs + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Internal + pr: "222222" + """)); + + // Add custom config at explicit path + FileSystem.AddFile("docs/custom/path/my-changelog.yml", new MockFileData( + // language=yaml + """ + block: + publish: + areas: + - Internal + """)); + } + + [Fact] + public void ConfigPathPropertyIsSet() => Block!.ConfigPath.Should().Be("custom/path/my-changelog.yml"); + + [Fact] + public void LoadsPublishBlockerFromExplicitConfig() => Block!.PublishBlocker.Should().NotBeNull(); + + [Fact] + public void PublishBlockerHasCorrectAreas() + { + Block!.PublishBlocker!.Areas.Should().NotBeNull(); + Block!.PublishBlocker!.Areas.Should().Contain("Internal"); + } + + [Fact] + public void FilteredEntriesExcludeBlockedAreas() + { + Html.Should().Contain("Regular feature"); + Html.Should().NotContain("Internal docs"); + } +} + +public class ChangelogConfigLoadFromDocsSubfolderTests : DirectiveTest +{ + public ChangelogConfigLoadFromDocsSubfolderTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Regular feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Other change + type: other + products: + - product: elasticsearch + target: 9.3.0 + pr: "222222" + """)); + + // Add config in docs/docs/changelog.yml (docs subfolder) + FileSystem.AddFile("docs/docs/changelog.yml", new MockFileData( + // language=yaml + """ + block: + publish: + types: + - other + """)); + } + + [Fact] + public void LoadsPublishBlockerFromDocsSubfolder() => Block!.PublishBlocker.Should().NotBeNull(); + + [Fact] + public void FilteredEntriesExcludeBlockedTypes() + { + Html.Should().Contain("Regular feature"); + Html.Should().NotContain("Other change"); + } +} + +public class ChangelogConfigNotFoundTests : DirectiveTest +{ + public ChangelogConfigNotFoundTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Regular feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + """)); + + [Fact] + public void PublishBlockerIsNullWhenNoConfig() => Block!.PublishBlocker.Should().BeNull(); + + [Fact] + public void RendersAllEntriesWhenNoConfig() => Html.Should().Contain("Regular feature"); + + [Fact] + public void NoErrorsEmittedForMissingConfig() => + Collector.Diagnostics.Should().NotContain(d => d.Message.Contains("changelog.yml")); +} + +public class ChangelogConfigExplicitPathNotFoundTests : DirectiveTest +{ + public ChangelogConfigExplicitPathNotFoundTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :config: nonexistent/config.yml + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Regular feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + """)); + + [Fact] + public void PublishBlockerIsNullWhenExplicitConfigNotFound() => Block!.PublishBlocker.Should().BeNull(); + + [Fact] + public void EmitsWarningForMissingExplicitConfig() => + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("nonexistent/config.yml") && d.Message.Contains("not found")); + + [Fact] + public void RendersAllEntriesWhenConfigNotFound() => Html.Should().Contain("Regular feature"); +} + +public class ChangelogConfigPriorityTests : DirectiveTest +{ + public ChangelogConfigPriorityTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Regular feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Deprecation notice + type: deprecation + products: + - product: elasticsearch + target: 9.3.0 + description: Deprecated. + impact: None. + action: Upgrade. + pr: "222222" + - title: Other change + type: other + products: + - product: elasticsearch + target: 9.3.0 + pr: "333333" + """)); + + // Add both config files - root should take priority + FileSystem.AddFile("docs/changelog.yml", new MockFileData( + // language=yaml + """ + block: + publish: + types: + - deprecation + """)); + + FileSystem.AddFile("docs/docs/changelog.yml", new MockFileData( + // language=yaml + """ + block: + publish: + types: + - other + """)); + } + + [Fact] + public void RootConfigTakesPriorityOverDocsSubfolder() + { + // Root config blocks deprecation, not other + Html.Should().Contain("Regular feature"); + Html.Should().NotContain("Deprecation notice"); + Html.Should().Contain("Other change"); + } +} + +public class ChangelogConfigEmptyBlockTests : DirectiveTest +{ + public ChangelogConfigEmptyBlockTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Regular feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + """)); + + // Config file exists but has no block section + FileSystem.AddFile("docs/changelog.yml", new MockFileData( + // language=yaml + """ + lifecycles: + - preview + - beta + - ga + """)); + } + + [Fact] + public void PublishBlockerIsNullWhenNoBlockSection() => Block!.PublishBlocker.Should().BeNull(); + + [Fact] + public void RendersAllEntriesWhenNoBlockSection() => Html.Should().Contain("Regular feature"); +} + +public class ChangelogConfigMixedBlockersTests : DirectiveTest +{ + public ChangelogConfigMixedBlockersTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Regular feature in Search + type: feature + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Search + pr: "111111" + - title: Deprecation in Search + type: deprecation + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Search + description: Deprecated. + impact: None. + action: Upgrade. + pr: "222222" + - title: Feature in Internal + type: feature + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Internal + pr: "333333" + - title: Bug fix + type: bug-fix + products: + - product: elasticsearch + target: 9.3.0 + pr: "444444" + """)); + + // Config with both type and area blockers + FileSystem.AddFile("docs/changelog.yml", new MockFileData( + // language=yaml + """ + block: + publish: + types: + - deprecation + areas: + - Internal + """)); + } + + [Fact] + public void PublishBlockerHasBothTypesAndAreas() + { + Block!.PublishBlocker.Should().NotBeNull(); + Block!.PublishBlocker!.Types.Should().Contain("deprecation"); + Block!.PublishBlocker!.Areas.Should().Contain("Internal"); + } + + [Fact] + public void FiltersEntriesMatchingBlockedTypes() => Html.Should().NotContain("Deprecation in Search"); + + [Fact] + public void FiltersEntriesMatchingBlockedAreas() => Html.Should().NotContain("Feature in Internal"); + + [Fact] + public void RendersNonBlockedEntries() + { + Html.Should().Contain("Regular feature in Search"); + Html.Should().Contain("Bug fix"); + } +} diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogHideLinksTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogHideLinksTests.cs new file mode 100644 index 000000000..381d6bde8 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogHideLinksTests.cs @@ -0,0 +1,472 @@ +// 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 System.IO.Abstractions.TestingHelpers; +using Elastic.Markdown.Myst.Directives.Changelog; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.Directives; + +/// +/// Tests for the ShouldHideLinksForRepo method which determines if links should be hidden +/// based on whether any component of the bundle's repository is private. +/// +public class ChangelogShouldHideLinksForRepoTests +{ + [Fact] + public void ReturnsTrue_WhenSingleRepoIsPrivate() + { + var privateRepos = new HashSet(StringComparer.OrdinalIgnoreCase) { "private-repo" }; + + var result = ChangelogInlineRenderer.ShouldHideLinksForRepo("private-repo", privateRepos); + + result.Should().BeTrue(); + } + + [Fact] + public void ReturnsFalse_WhenSingleRepoIsNotPrivate() + { + var privateRepos = new HashSet(StringComparer.OrdinalIgnoreCase) { "other-repo" }; + + var result = ChangelogInlineRenderer.ShouldHideLinksForRepo("elasticsearch", privateRepos); + + result.Should().BeFalse(); + } + + [Fact] + public void ReturnsFalse_WhenPrivateReposIsEmpty() + { + var privateRepos = new HashSet(StringComparer.OrdinalIgnoreCase); + + var result = ChangelogInlineRenderer.ShouldHideLinksForRepo("elasticsearch", privateRepos); + + result.Should().BeFalse(); + } + + [Fact] + public void ReturnsTrue_WhenMergedRepoContainsPrivateRepo() + { + // Merged bundle repos are joined with '+' + var privateRepos = new HashSet(StringComparer.OrdinalIgnoreCase) { "private-repo" }; + + var result = ChangelogInlineRenderer.ShouldHideLinksForRepo("elasticsearch+kibana+private-repo", privateRepos); + + result.Should().BeTrue(); + } + + [Fact] + public void ReturnsFalse_WhenMergedRepoContainsNoPrivateRepos() + { + var privateRepos = new HashSet(StringComparer.OrdinalIgnoreCase) { "other-private" }; + + var result = ChangelogInlineRenderer.ShouldHideLinksForRepo("elasticsearch+kibana", privateRepos); + + result.Should().BeFalse(); + } + + [Fact] + public void IsCaseInsensitive_ForRepoNames() + { + var privateRepos = new HashSet(StringComparer.OrdinalIgnoreCase) { "Private-Repo" }; + + var result = ChangelogInlineRenderer.ShouldHideLinksForRepo("private-repo", privateRepos); + + result.Should().BeTrue(); + } + + [Fact] + public void HandlesWhitespace_InMergedRepoNames() + { + var privateRepos = new HashSet(StringComparer.OrdinalIgnoreCase) { "private-repo" }; + + // The split uses TrimEntries, so whitespace around names should be handled + var result = ChangelogInlineRenderer.ShouldHideLinksForRepo("elasticsearch + private-repo + kibana", privateRepos); + + result.Should().BeTrue(); + } +} + +/// +/// Tests that links are shown for public repositories. +/// +public class ChangelogLinksDefaultBehaviorTests : DirectiveTest +{ + public ChangelogLinksDefaultBehaviorTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature with PR and issues + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "123456" + issues: + - "78901" + - "78902" + """)); + + [Fact] + public void PrivateRepositoriesPropertyIsAccessible() => + // The PrivateRepositories property should be accessible + // (may contain repos from embedded assembler.yml) + Block!.PrivateRepositories.Should().NotBeNull(); + + [Fact] + public void RendersPrLinksForPublicRepo() + { + // elasticsearch is a public repo, so PR link should be visible in the output + Html.Should().Contain("123456"); + Html.Should().Contain("github.com"); + } + + [Fact] + public void RendersIssueLinksForPublicRepo() + { + // elasticsearch is public, so issue links should be visible + Html.Should().Contain("78901"); + Html.Should().Contain("78902"); + } +} + +/// +/// Tests that links are hidden when the bundle's repository is marked as private. +/// Uses internal setter to simulate private repo detection from assembler.yml. +/// +public class ChangelogLinksHiddenForPrivateRepoTests : DirectiveTest +{ + public ChangelogLinksHiddenForPrivateRepoTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature with PR and issues + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "123456" + issues: + - "78901" + - "78902" + """)); + + public override async ValueTask InitializeAsync() + { + await base.InitializeAsync(); + // Simulate that 'elasticsearch' is a private repository + Block!.PrivateRepositories.Add("elasticsearch"); + } + + [Fact] + public void PrivateRepositoriesContainsConfiguredRepo() => + Block!.PrivateRepositories.Should().Contain("elasticsearch"); + + [Fact] + public void HidesPrLinksForPrivateRepo() + { + // Re-render after setting private repos + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + + // PR links should NOT be rendered as clickable links + markdown.Should().NotBeNull(); + markdown.Should().Contain("123456"); // PR number still appears + markdown.Should().Contain("%"); // Links are commented out + } + + [Fact] + public void HidesIssueLinksForPrivateRepo() + { + // Re-render after setting private repos + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + + // Issue links should be commented out + markdown.Should().Contain("78901"); + markdown.Should().Contain("78902"); + markdown.Should().Contain("%"); // Links are commented out + } +} + +/// +/// Tests link hiding behavior with detailed entries (breaking changes, deprecations). +/// Uses :type: all to show breaking changes and deprecations. +/// +public class ChangelogLinksHiddenInDetailedEntriesTests : DirectiveTest +{ + public ChangelogLinksHiddenInDetailedEntriesTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: all + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Breaking change with PR + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: API has changed. + impact: Users must update. + action: Follow migration guide. + pr: "999888" + issues: + - "777666" + - title: Deprecation with PR + type: deprecation + products: + - product: elasticsearch + target: 9.3.0 + description: Old API deprecated. + impact: Will be removed. + action: Use new API. + pr: "555444" + """)); + + public override async ValueTask InitializeAsync() + { + await base.InitializeAsync(); + // Simulate that 'elasticsearch' is a private repository + Block!.PrivateRepositories.Add("elasticsearch"); + } + + [Fact] + public void HidesLinksInBreakingChangesSection() + { + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + + // The breaking change section should have the links hidden (commented out) + markdown.Should().Contain("Breaking change with PR"); + markdown.Should().Contain("999888"); // PR number appears + markdown.Should().Contain("%"); // Links are commented out + } + + [Fact] + public void HidesLinksInDeprecationsSection() + { + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + + // The deprecation section should have the links hidden + markdown.Should().Contain("Deprecation with PR"); + markdown.Should().Contain("555444"); // PR number appears + markdown.Should().Contain("%"); // Links are commented out + } + + [Fact] + public void RendersImpactAndActionSections() + { + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + + // Impact and action should still be rendered + markdown.Should().Contain("Impact"); + markdown.Should().Contain("Users must update"); + markdown.Should().Contain("Action"); + markdown.Should().Contain("Follow migration guide"); + } +} + +/// +/// Tests that links are shown for public repos even when some private repos are configured. +/// +public class ChangelogLinksShownForPublicRepoTests : DirectiveTest +{ + public ChangelogLinksShownForPublicRepoTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature from public repo + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + """)); + + public override async ValueTask InitializeAsync() + { + await base.InitializeAsync(); + // Configure a different repo as private - not elasticsearch + Block!.PrivateRepositories.Add("private-internal-repo"); + } + + [Fact] + public void ShowsPrLinksForPublicRepo() + { + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + + // PR links should be visible (not commented out) since elasticsearch is public + markdown.Should().Contain("111111"); + // The link should be a proper GitHub URL, not commented out + markdown.Should().Contain("[#111111]"); + markdown.Should().Contain("github.com/elastic/elasticsearch/pull/111111"); + } +} + +/// +/// Tests link hiding with merged bundles where one repo is private. +/// +public class ChangelogLinksWithMergedBundlesTests : DirectiveTest +{ + public ChangelogLinksWithMergedBundlesTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + // Add bundles from two repos with the same target version (will be merged) + FileSystem.AddFile("docs/changelog/bundles/elasticsearch-2025-08-05.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 2025-08-05 + entries: + - title: ES Feature + type: feature + products: + - product: elasticsearch + target: 2025-08-05 + pr: "111111" + """)); + + FileSystem.AddFile("docs/changelog/bundles/kibana-2025-08-05.yaml", new MockFileData( + // language=yaml + """ + products: + - product: kibana + target: 2025-08-05 + entries: + - title: Kibana Feature + type: feature + products: + - product: kibana + target: 2025-08-05 + pr: "222222" + """)); + } + + public override async ValueTask InitializeAsync() + { + await base.InitializeAsync(); + // Kibana is a private repo + Block!.PrivateRepositories.Add("kibana"); + } + + [Fact] + public void MergedBundleRepoContainsBothRepos() + { + // Bundles with same target version are merged, repo names combined with '+' + Block!.LoadedBundles.Should().HaveCount(1); + Block!.LoadedBundles[0].Repo.Should().Contain("elasticsearch"); + Block!.LoadedBundles[0].Repo.Should().Contain("kibana"); + Block!.LoadedBundles[0].Repo.Should().Contain("+"); + } + + [Fact] + public void HidesLinksWhenAnyMergedRepoIsPrivate() + { + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + + // Since kibana is private, all links in the merged bundle should be hidden + markdown.Should().Contain("ES Feature"); + markdown.Should().Contain("Kibana Feature"); + // Links should be commented out (% prefix) + markdown.Should().Contain("%"); + } +} + +/// +/// Tests that merged bundles with only public repos show links. +/// +public class ChangelogLinksWithMergedPublicReposTests : DirectiveTest +{ + public ChangelogLinksWithMergedPublicReposTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + // Add bundles from two public repos with the same target version + FileSystem.AddFile("docs/changelog/bundles/elasticsearch-2025-08-05.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 2025-08-05 + entries: + - title: ES Feature + type: feature + products: + - product: elasticsearch + target: 2025-08-05 + pr: "111111" + """)); + + FileSystem.AddFile("docs/changelog/bundles/kibana-2025-08-05.yaml", new MockFileData( + // language=yaml + """ + products: + - product: kibana + target: 2025-08-05 + entries: + - title: Kibana Feature + type: feature + products: + - product: kibana + target: 2025-08-05 + pr: "222222" + """)); + } + + public override async ValueTask InitializeAsync() + { + await base.InitializeAsync(); + // Only unrelated repos are private - elasticsearch and kibana are public + Block!.PrivateRepositories.Add("some-other-private-repo"); + } + + [Fact] + public void ShowsLinksWhenAllMergedReposArePublic() + { + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + + // Both repos are public, so links should be visible + markdown.Should().Contain("ES Feature"); + markdown.Should().Contain("Kibana Feature"); + // Links should be proper GitHub URLs + markdown.Should().Contain("[#111111]"); + markdown.Should().Contain("[#222222]"); + markdown.Should().Contain("github.com"); + } +} diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogMergeTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogMergeTests.cs new file mode 100644 index 000000000..4aae76da2 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogMergeTests.cs @@ -0,0 +1,365 @@ +// 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 System.IO.Abstractions.TestingHelpers; +using Elastic.Markdown.Myst.Directives.Changelog; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.Directives; + +/// +/// Tests for automatic merging of bundles with the same target version/date. +/// Merging is now the default behavior (no longer requires `:merge:` option). +/// +public class ChangelogMergeSameTargetTests : DirectiveTest +{ + public ChangelogMergeSameTargetTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + // Cloud Serverless scenario: multiple repos contributing to the same dated release + FileSystem.AddFile("docs/changelog/bundles/kibana-2025-08-05.yaml", new MockFileData( + // language=yaml + """ + products: + - product: kibana + target: 2025-08-05 + entries: + - title: Kibana feature for August 5th + type: feature + products: + - product: kibana + target: 2025-08-05 + areas: + - Dashboard + pr: "111111" + """)); + + FileSystem.AddFile("docs/changelog/bundles/elasticsearch-2025-08-05.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 2025-08-05 + entries: + - title: Elasticsearch feature for August 5th + type: feature + products: + - product: elasticsearch + target: 2025-08-05 + areas: + - Search + pr: "222222" + - title: Elasticsearch bugfix for August 5th + type: bug-fix + products: + - product: elasticsearch + target: 2025-08-05 + pr: "222223" + """)); + + FileSystem.AddFile("docs/changelog/bundles/serverless-2025-08-05.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch-serverless + target: 2025-08-05 + entries: + - title: Serverless feature for August 5th + type: feature + products: + - product: elasticsearch-serverless + target: 2025-08-05 + areas: + - API + pr: "333333" + """)); + + // A different release date with single bundle + FileSystem.AddFile("docs/changelog/bundles/kibana-2025-08-01.yaml", new MockFileData( + // language=yaml + """ + products: + - product: kibana + target: 2025-08-01 + entries: + - title: Kibana feature for August 1st + type: feature + products: + - product: kibana + target: 2025-08-01 + pr: "444444" + """)); + } + + [Fact] + public void MergesBundlesWithSameTargetByDefault() => + // Three bundles with 2025-08-05 should be merged into one + // Plus one bundle with 2025-08-01 = 2 total bundles + Block!.LoadedBundles.Should().HaveCount(2); + + [Fact] + public void MergedBundleContainsAllEntries() + { + // The 2025-08-05 merged bundle should have 4 entries (1 + 2 + 1) + var aug5Bundle = Block!.LoadedBundles.FirstOrDefault(b => b.Version == "2025-08-05"); + aug5Bundle.Should().NotBeNull(); + aug5Bundle!.Entries.Should().HaveCount(4); + } + + [Fact] + public void MergedBundleHasCombinedRepoName() + { + var aug5Bundle = Block!.LoadedBundles.FirstOrDefault(b => b.Version == "2025-08-05"); + aug5Bundle.Should().NotBeNull(); + // Repos should be combined and sorted alphabetically + aug5Bundle!.Repo.Should().Contain("elasticsearch"); + aug5Bundle.Repo.Should().Contain("kibana"); + aug5Bundle.Repo.Should().Contain("elasticsearch-serverless"); + aug5Bundle.Repo.Should().Contain("+"); + } + + [Fact] + public void RendersOnlyOneVersionHeaderPerMergedTarget() + { + // Should render only one version header for 2025-08-05, not three separate ones + // Count occurrences of the version string in h2 context + var aug05Count = CountOccurrences(Html, ">2025-08-05<"); + var aug01Count = CountOccurrences(Html, ">2025-08-01<"); + + aug05Count.Should().Be(1, "Should have exactly 1 version header for 2025-08-05 (merged)"); + aug01Count.Should().Be(1, "Should have exactly 1 version header for 2025-08-01"); + } + + [Fact] + public void RendersAllEntriesFromMergedBundles() + { + Html.Should().Contain("Kibana feature for August 5th"); + Html.Should().Contain("Elasticsearch feature for August 5th"); + Html.Should().Contain("Elasticsearch bugfix for August 5th"); + Html.Should().Contain("Serverless feature for August 5th"); + } + + [Fact] + public void MaintainsCorrectDateOrder() + { + // 2025-08-05 should appear before 2025-08-01 (descending order) + var idx05 = Html.IndexOf("2025-08-05", StringComparison.Ordinal); + var idx01 = Html.IndexOf("2025-08-01", StringComparison.Ordinal); + + idx05.Should().BeLessThan(idx01, "2025-08-05 should appear before 2025-08-01"); + } + + private static int CountOccurrences(string text, string pattern) + { + var count = 0; + var index = 0; + while ((index = text.IndexOf(pattern, index, StringComparison.Ordinal)) != -1) + { + count++; + index += pattern.Length; + } + return count; + } +} + +/// +/// Tests that bundles with different target versions remain separate (not merged). +/// +public class ChangelogMergeDifferentTargetsTests : DirectiveTest +{ + public ChangelogMergeDifferentTargetsTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + // Bundles with different targets should remain separate + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature in 9.3.0 + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + """)); + + FileSystem.AddFile("docs/changelog/bundles/9.2.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.2.0 + entries: + - title: Feature in 9.2.0 + type: feature + products: + - product: elasticsearch + target: 9.2.0 + pr: "222222" + """)); + + FileSystem.AddFile("docs/changelog/bundles/9.1.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.1.0 + entries: + - title: Feature in 9.1.0 + type: feature + products: + - product: elasticsearch + target: 9.1.0 + pr: "333333" + """)); + } + + [Fact] + public void KeepsDifferentTargetsSeparate() + { + // All three bundles have different targets, so no merging should happen + Block!.LoadedBundles.Should().HaveCount(3); + } + + [Fact] + public void RendersAllVersionsSeparately() + { + Html.Should().Contain("9.3.0"); + Html.Should().Contain("9.2.0"); + Html.Should().Contain("9.1.0"); + } + + [Fact] + public void MaintainsSemverOrder() + { + var idx93 = Html.IndexOf("9.3.0", StringComparison.Ordinal); + var idx92 = Html.IndexOf("9.2.0", StringComparison.Ordinal); + var idx91 = Html.IndexOf("9.1.0", StringComparison.Ordinal); + + idx93.Should().BeLessThan(idx92, "9.3.0 should appear before 9.2.0"); + idx92.Should().BeLessThan(idx91, "9.2.0 should appear before 9.1.0"); + } +} + +/// +/// Tests that merging works correctly with a single bundle (no actual merge needed). +/// +public class ChangelogMergeSingleBundleTests : DirectiveTest +{ + public ChangelogMergeSingleBundleTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature in 9.3.0 + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Bug fix in 9.3.0 + type: bug-fix + products: + - product: elasticsearch + target: 9.3.0 + pr: "111112" + """)); + + [Fact] + public void SingleBundleRemainsUnchanged() => + Block!.LoadedBundles.Should().HaveCount(1); + + [Fact] + public void SingleBundleHasCorrectVersion() => + Block!.LoadedBundles[0].Version.Should().Be("9.3.0"); + + [Fact] + public void SingleBundleHasAllEntries() => + Block!.LoadedBundles[0].Entries.Should().HaveCount(2); + + [Fact] + public void SingleBundleRendersCorrectly() + { + Html.Should().Contain("Feature in 9.3.0"); + Html.Should().Contain("Bug fix in 9.3.0"); + } +} + +/// +/// Tests that merging preserves sort order when bundles have both semver and date-based versions. +/// +public class ChangelogMergeMixedVersionTypesTests : DirectiveTest +{ + public ChangelogMergeMixedVersionTypesTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + // Semver version + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature in 9.3.0 + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + """)); + + // Date-based version + FileSystem.AddFile("docs/changelog/bundles/2025-08-05.yaml", new MockFileData( + // language=yaml + """ + products: + - product: kibana + target: 2025-08-05 + entries: + - title: Feature for August 5th + type: feature + products: + - product: kibana + target: 2025-08-05 + pr: "222222" + """)); + } + + [Fact] + public void MixedVersionTypesRemainSeparate() + { + // Semver and date-based versions should not be merged + Block!.LoadedBundles.Should().HaveCount(2); + } + + [Fact] + public void RendersAllVersions() + { + Html.Should().Contain("9.3.0"); + Html.Should().Contain("2025-08-05"); + } +} diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogPathResolutionTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogPathResolutionTests.cs new file mode 100644 index 000000000..629566ac9 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogPathResolutionTests.cs @@ -0,0 +1,328 @@ +// 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 System.IO.Abstractions.TestingHelpers; +using Elastic.Markdown.Myst.Directives.Changelog; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.Directives; + +/// +/// Tests for path resolution in the changelog directive. +/// Verifies that Path.Combine issues are properly handled for: +/// - Relative paths (combined with docset root) +/// - Docset-root-relative paths (starting with '/', combined with docset root after trimming) +/// - Absolute filesystem paths (used as-is, prevents Path.Combine from silently dropping base) +/// +public class ChangelogBundlesFolderRelativePathTests : DirectiveTest +{ + public ChangelogBundlesFolderRelativePathTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} custom/path/bundles + ::: + """) => FileSystem.AddFile("docs/custom/path/bundles/1.0.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: test-product + target: 1.0.0 + entries: + - title: Test feature + type: feature + products: + - product: test-product + target: 1.0.0 + pr: "12345" + """)); + + [Fact] + public void ResolvesRelativePath() => Block!.Found.Should().BeTrue(); + + [Fact] + public void PathCombinedWithDocsetRoot() => + Block!.BundlesFolderPath.Should().EndWith("custom/path/bundles".Replace('/', Path.DirectorySeparatorChar)); + + [Fact] + public void RendersContent() => Html.Should().Contain("Test feature"); +} + +public class ChangelogBundlesFolderDocsetRootRelativeTests : DirectiveTest +{ + public ChangelogBundlesFolderDocsetRootRelativeTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} /release-notes/versions + ::: + """) => FileSystem.AddFile("docs/release-notes/versions/2.0.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: test-product + target: 2.0.0 + entries: + - title: Another feature + type: feature + products: + - product: test-product + target: 2.0.0 + pr: "67890" + """)); + + [Fact] + public void ResolvesDocsetRootRelativePath() => Block!.Found.Should().BeTrue(); + + [Fact] + public void SlashPrefixIsTrimmed() => + Block!.BundlesFolderPath.Should().EndWith("release-notes/versions".Replace('/', Path.DirectorySeparatorChar)); + + [Fact] + public void PathDoesNotContainDoubleSlashes() => + Block!.BundlesFolderPath.Should().NotContain("//"); + + [Fact] + public void RendersContent() => Html.Should().Contain("Another feature"); +} + +public class ChangelogConfigRelativePathTests : DirectiveTest +{ + public ChangelogConfigRelativePathTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :config: config/my-changelog.yml + ::: + """) + { + FileSystem.AddFile("docs/changelog/bundles/1.0.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: test-product + target: 1.0.0 + entries: + - title: Feature entry + type: feature + products: + - product: test-product + target: 1.0.0 + pr: "11111" + - title: Blocked entry + type: deprecation + products: + - product: test-product + target: 1.0.0 + description: Deprecated. + impact: None. + action: Upgrade. + pr: "22222" + """)); + + FileSystem.AddFile("docs/config/my-changelog.yml", new MockFileData( + // language=yaml + """ + block: + publish: + types: + - deprecation + """)); + } + + [Fact] + public void LoadsConfigFromRelativePath() => Block!.PublishBlocker.Should().NotBeNull(); + + [Fact] + public void ConfigBlocksCorrectTypes() => Block!.PublishBlocker!.Types.Should().Contain("deprecation"); + + [Fact] + public void FiltersBlockedEntries() + { + Html.Should().Contain("Feature entry"); + Html.Should().NotContain("Blocked entry"); + } +} + +public class ChangelogConfigDocsetRootRelativePathTests : DirectiveTest +{ + public ChangelogConfigDocsetRootRelativePathTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :config: /settings/changelog-config.yml + ::: + """) + { + FileSystem.AddFile("docs/changelog/bundles/1.0.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: test-product + target: 1.0.0 + entries: + - title: Regular feature + type: feature + products: + - product: test-product + target: 1.0.0 + pr: "33333" + - title: Internal feature + type: feature + products: + - product: test-product + target: 1.0.0 + areas: + - Internal + pr: "44444" + """)); + + FileSystem.AddFile("docs/settings/changelog-config.yml", new MockFileData( + // language=yaml + """ + block: + publish: + areas: + - Internal + """)); + } + + [Fact] + public void LoadsConfigFromDocsetRootRelativePath() => Block!.PublishBlocker.Should().NotBeNull(); + + [Fact] + public void ConfigBlocksCorrectAreas() => Block!.PublishBlocker!.Areas.Should().Contain("Internal"); + + [Fact] + public void FiltersBlockedEntries() + { + Html.Should().Contain("Regular feature"); + Html.Should().NotContain("Internal feature"); + } +} + +public class ChangelogBundlesFolderNestedRelativePathTests : DirectiveTest +{ + public ChangelogBundlesFolderNestedRelativePathTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} deeply/nested/path/to/bundles + ::: + """) => FileSystem.AddFile("docs/deeply/nested/path/to/bundles/3.0.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: nested-product + target: 3.0.0 + entries: + - title: Nested feature + type: feature + products: + - product: nested-product + target: 3.0.0 + pr: "99999" + """)); + + [Fact] + public void ResolvesDeepNestedPath() => Block!.Found.Should().BeTrue(); + + [Fact] + public void RendersContent() => Html.Should().Contain("Nested feature"); +} + +/// +/// Tests that verify the path does not erroneously get rooted when processing paths. +/// These tests ensure Path.Combine behavior is correct for edge cases. +/// +/// +/// Note: Testing true absolute filesystem paths (like C:\path on Windows) is platform-dependent. +/// The ResolvePath method uses Path.IsPathRooted() which behaves differently on Windows vs Unix. +/// On Windows: C:\path returns true for IsPathRooted +/// On Unix: /path returns true for IsPathRooted, but our convention treats leading / as docset-root-relative +/// +/// The implementation correctly handles this by checking StartsWith('/') before IsPathRooted, +/// ensuring our docset-root-relative convention takes precedence. +/// +public class ChangelogPathEdgeCaseTests : DirectiveTest +{ + public ChangelogPathEdgeCaseTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} ./relative/bundles + ::: + """) => FileSystem.AddFile("docs/relative/bundles/1.0.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: edge-product + target: 1.0.0 + entries: + - title: Edge case feature + type: feature + products: + - product: edge-product + target: 1.0.0 + pr: "55555" + """)); + + [Fact] + public void ResolvesPathWithDotSlashPrefix() => Block!.Found.Should().BeTrue(); + + [Fact] + public void RendersContent() => Html.Should().Contain("Edge case feature"); +} + +public class ChangelogConfigAndBundlesRelativePathsTests : DirectiveTest +{ + public ChangelogConfigAndBundlesRelativePathsTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} bundles/v1 + :config: config/changelog.yml + ::: + """) + { + FileSystem.AddFile("docs/bundles/v1/1.0.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: combined-product + target: 1.0.0 + entries: + - title: Combined feature + type: feature + products: + - product: combined-product + target: 1.0.0 + pr: "66666" + - title: Blocked by config + type: other + products: + - product: combined-product + target: 1.0.0 + pr: "77777" + """)); + + FileSystem.AddFile("docs/config/changelog.yml", new MockFileData( + // language=yaml + """ + block: + publish: + types: + - other + """)); + } + + [Fact] + public void BothPathsResolveCorrectly() + { + Block!.Found.Should().BeTrue(); + Block!.PublishBlocker.Should().NotBeNull(); + } + + [Fact] + public void ConfigFiltersEntries() + { + Html.Should().Contain("Combined feature"); + Html.Should().NotContain("Blocked by config"); + } +} diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogSubsectionsTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogSubsectionsTests.cs new file mode 100644 index 000000000..3ac747d9b --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogSubsectionsTests.cs @@ -0,0 +1,146 @@ +// 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 System.IO.Abstractions.TestingHelpers; +using Elastic.Markdown.Myst.Directives.Changelog; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.Directives; + +public class ChangelogSubsectionsDisabledByDefaultTests : DirectiveTest +{ + public ChangelogSubsectionsDisabledByDefaultTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature in Search + type: feature + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Search + pr: "111111" + - title: Feature in Indexing + type: feature + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Indexing + pr: "222222" + """)); + + [Fact] + public void SubsectionsPropertyDefaultsToFalse() => Block!.Subsections.Should().BeFalse(); + + [Fact] + public void DoesNotRenderAreaHeaders() + { + // When subsections is false, area headers should not be rendered + Html.Should().NotContain("Search"); + Html.Should().NotContain("Indexing"); + } + + [Fact] + public void RendersEntriesWithoutGrouping() + { + // Both entries should be rendered without area grouping + Html.Should().Contain("Feature in Search"); + Html.Should().Contain("Feature in Indexing"); + } +} + +public class ChangelogSubsectionsEnabledTests : DirectiveTest +{ + public ChangelogSubsectionsEnabledTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :subsections: + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature in Search + type: feature + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Search + pr: "111111" + - title: Feature in Indexing + type: feature + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Indexing + pr: "222222" + """)); + + [Fact] + public void SubsectionsPropertyIsTrue() => Block!.Subsections.Should().BeTrue(); + + [Fact] + public void RendersAreaHeaders() + { + // When subsections is true, area headers should be rendered + Html.Should().Contain("Search"); + Html.Should().Contain("Indexing"); + } + + [Fact] + public void RendersEntriesUnderCorrectAreas() + { + // Both entries should be rendered + Html.Should().Contain("Feature in Search"); + Html.Should().Contain("Feature in Indexing"); + } +} + +public class ChangelogSubsectionsExplicitFalseTests : DirectiveTest +{ + public ChangelogSubsectionsExplicitFalseTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :subsections: false + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature in Search + type: feature + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Search + pr: "111111" + """)); + + [Fact] + public void SubsectionsPropertyIsFalse() => Block!.Subsections.Should().BeFalse(); + + [Fact] + public void DoesNotRenderAreaHeaders() => Html.Should().NotContain("Search"); +} diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogTests.cs deleted file mode 100644 index 327458002..000000000 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogTests.cs +++ /dev/null @@ -1,351 +0,0 @@ -// 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 System.IO.Abstractions.TestingHelpers; -using Elastic.Markdown.Myst.Directives.Changelog; -using FluentAssertions; - -namespace Elastic.Markdown.Tests.Directives; - -public class ChangelogBasicTests : DirectiveTest -{ - public ChangelogBasicTests(ITestOutputHelper output) : base(output, -""" -:::{changelog} -::: -""") => - // Create the default bundles folder with a test bundle - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( -""" -products: -- product: elasticsearch - target: 9.3.0 - lifecycle: ga -entries: -- title: Add new feature - type: feature - products: - - product: elasticsearch - target: 9.3.0 - areas: - - Search - pr: "123456" - description: This is a great new feature. -- title: Fix important bug - type: bug-fix - products: - - product: elasticsearch - target: 9.3.0 - areas: - - Indexing - pr: "123457" -""")); - - [Fact] - public void ParsesChangelogBlock() => Block.Should().NotBeNull(); - - [Fact] - public void SetsCorrectDirectiveType() => Block!.Directive.Should().Be("changelog"); - - [Fact] - public void FindsBundlesFolder() => Block!.Found.Should().BeTrue(); - - [Fact] - public void SetsCorrectBundlesFolderPath() => Block!.BundlesFolderPath.Should().EndWith("changelog/bundles"); - - [Fact] - public void LoadsBundles() => Block!.LoadedBundles.Should().HaveCount(1); - - [Fact] - public void RendersMarkdownContent() - { - Html.Should().Contain("9.3.0"); - Html.Should().Contain("Features and enhancements"); - Html.Should().Contain("Add new feature"); - Html.Should().Contain("Fixes"); - Html.Should().Contain("Fix important bug"); - } -} - -public class ChangelogMultipleBundlesTests : DirectiveTest -{ - public ChangelogMultipleBundlesTests(ITestOutputHelper output) : base(output, -""" -:::{changelog} -::: -""") - { - // Create multiple bundles with different versions - FileSystem.AddFile("docs/changelog/bundles/9.2.0.yaml", new MockFileData( -""" -products: -- product: elasticsearch - target: 9.2.0 -entries: -- title: Feature in 9.2.0 - type: feature - products: - - product: elasticsearch - target: 9.2.0 - pr: "111111" -""")); - - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( -""" -products: -- product: elasticsearch - target: 9.3.0 -entries: -- title: Feature in 9.3.0 - type: feature - products: - - product: elasticsearch - target: 9.3.0 - pr: "222222" -""")); - - FileSystem.AddFile("docs/changelog/bundles/9.10.0.yaml", new MockFileData( -""" -products: -- product: elasticsearch - target: 9.10.0 -entries: -- title: Feature in 9.10.0 - type: feature - products: - - product: elasticsearch - target: 9.10.0 - pr: "333333" -""")); - } - - [Fact] - public void LoadsBundles() => Block!.LoadedBundles.Should().HaveCount(3); - - [Fact] - public void RendersInSemverOrder() - { - // Should be sorted by semver descending: 9.10.0 > 9.3.0 > 9.2.0 - var idx910 = Html.IndexOf("9.10.0", StringComparison.Ordinal); - var idx93 = Html.IndexOf("9.3.0", StringComparison.Ordinal); - var idx92 = Html.IndexOf("9.2.0", StringComparison.Ordinal); - - idx910.Should().BeLessThan(idx93, "9.10.0 should appear before 9.3.0"); - idx93.Should().BeLessThan(idx92, "9.3.0 should appear before 9.2.0"); - } - - [Fact] - public void RendersAllVersions() - { - Html.Should().Contain("9.10.0"); - Html.Should().Contain("9.3.0"); - Html.Should().Contain("9.2.0"); - } -} - -public class ChangelogCustomPathTests : DirectiveTest -{ - public ChangelogCustomPathTests(ITestOutputHelper output) : base(output, -""" -:::{changelog} release-notes/bundles -::: -""") => FileSystem.AddFile("docs/release-notes/bundles/1.0.0.yaml", new MockFileData( -""" -products: -- product: my-product - target: 1.0.0 -entries: -- title: First release - type: feature - products: - - product: my-product - target: 1.0.0 - pr: "1" -""")); - - [Fact] - public void FindsBundlesFolder() => Block!.Found.Should().BeTrue(); - - [Fact] - public void SetsCorrectBundlesFolderPath() => Block!.BundlesFolderPath.Should().EndWith("release-notes/bundles"); - - [Fact] - public void RendersContent() - { - Html.Should().Contain("1.0.0"); - Html.Should().Contain("First release"); - } -} - -public class ChangelogNotFoundTests(ITestOutputHelper output) : DirectiveTest(output, -""" -:::{changelog} missing-bundles -::: -""") -{ - [Fact] - public void ReportsFolderNotFound() => Block!.Found.Should().BeFalse(); - - [Fact] - public void EmitsErrorForMissingFolder() - { - Collector.Diagnostics.Should().NotBeNullOrEmpty(); - Collector.Diagnostics.Should().OnlyContain(d => d.Message.Contains("does not exist")); - } -} - -public class ChangelogDefaultPathMissingTests(ITestOutputHelper output) : DirectiveTest(output, -""" -:::{changelog} -::: -""") -{ - [Fact] - public void EmitsErrorForMissingDefaultFolder() - { - // No bundles folder created, so it should emit an error - Collector.Diagnostics.Should().NotBeNullOrEmpty(); - Collector.Diagnostics.Should().OnlyContain(d => d.Message.Contains("does not exist")); - } -} - -public class ChangelogWithBreakingChangesTests : DirectiveTest -{ - public ChangelogWithBreakingChangesTests(ITestOutputHelper output) : base(output, -""" -:::{changelog} -::: -""") => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( -""" -products: -- product: elasticsearch - target: 9.3.0 -entries: -- title: Breaking change in API - type: breaking-change - products: - - product: elasticsearch - target: 9.3.0 - description: The API has changed significantly. - impact: Users must update their code. - action: Follow the migration guide. - pr: "222222" -""")); - - [Fact] - public void RendersBreakingChangesSection() - { - Html.Should().Contain("Breaking changes"); - Html.Should().Contain("Breaking change in API"); - } - - [Fact] - public void RendersImpactAndAction() - { - Html.Should().Contain("Impact"); - Html.Should().Contain("Users must update their code"); - Html.Should().Contain("Action"); - Html.Should().Contain("Follow the migration guide"); - } -} - -public class ChangelogWithDeprecationsTests : DirectiveTest -{ - public ChangelogWithDeprecationsTests(ITestOutputHelper output) : base(output, -""" -:::{changelog} -::: -""") => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( -""" -products: -- product: elasticsearch - target: 9.3.0 -entries: -- title: Deprecated old API - type: deprecation - products: - - product: elasticsearch - target: 9.3.0 - description: The old API is deprecated. - impact: The API will be removed in a future version. - action: Use the new API instead. - pr: "333333" -""")); - - [Fact] - public void RendersDeprecationsSection() - { - Html.Should().Contain("Deprecations"); - Html.Should().Contain("Deprecated old API"); - } -} - -public class ChangelogEmptyBundleTests : DirectiveTest -{ - public ChangelogEmptyBundleTests(ITestOutputHelper output) : base(output, -""" -:::{changelog} -::: -""") => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( -""" -products: -- product: elasticsearch - target: 9.3.0 -entries: [] -""")); - - [Fact] - public void HandlesEmptyBundle() - { - Html.Should().Contain("No new features, enhancements, or fixes"); - } -} - -public class ChangelogEmptyFolderTests : DirectiveTest -{ - public ChangelogEmptyFolderTests(ITestOutputHelper output) : base(output, -""" -:::{changelog} -::: -""") => - // Create the folder but don't add any YAML files - FileSystem.AddDirectory("docs/changelog/bundles"); - - [Fact] - public void ReportsFolderEmpty() => Block!.Found.Should().BeFalse(); - - [Fact] - public void EmitsErrorForEmptyFolder() - { - Collector.Diagnostics.Should().NotBeNullOrEmpty(); - Collector.Diagnostics.Should().OnlyContain(d => d.Message.Contains("contains no YAML files")); - } -} - -public class ChangelogAbsolutePathTests : DirectiveTest -{ - public ChangelogAbsolutePathTests(ITestOutputHelper output) : base(output, -""" -:::{changelog} /release-notes/bundles -::: -""") => FileSystem.AddFile("docs/release-notes/bundles/9.3.0.yaml", new MockFileData( -""" -products: -- product: elasticsearch - target: 9.3.0 -entries: -- title: Test feature - type: feature - products: - - product: elasticsearch - target: 9.3.0 - pr: "444444" -""")); - - [Fact] - public void FindsBundlesFolderWithAbsolutePath() => Block!.Found.Should().BeTrue(); - - [Fact] - public void SetsCorrectBundlesFolderPath() => Block!.BundlesFolderPath.Should().Contain("release-notes"); -} diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogTypeFilterTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogTypeFilterTests.cs new file mode 100644 index 000000000..130341655 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogTypeFilterTests.cs @@ -0,0 +1,693 @@ +// 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 System.IO.Abstractions.TestingHelpers; +using Elastic.Markdown.Myst.Directives.Changelog; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.Directives; + +/// +/// Tests for the :type: parameter on the changelog directive. +/// By default (no :type:), the directive excludes known issues, breaking changes, and deprecations. +/// With :type: all, all entry types are shown. +/// With :type: breaking-change, only breaking changes are shown. +/// With :type: deprecation, only deprecations are shown. +/// With :type: known-issue, only known issues are shown. +/// +public class ChangelogTypeFilterDefaultTests : DirectiveTest +{ + public ChangelogTypeFilterDefaultTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: New feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Bug fix + type: bug-fix + products: + - product: elasticsearch + target: 9.3.0 + pr: "222222" + - title: Breaking API change + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: API changed. + impact: Users must update. + action: Follow guide. + pr: "333333" + - title: Known issue + type: known-issue + products: + - product: elasticsearch + target: 9.3.0 + description: Issue exists. + impact: Some impact. + action: Workaround available. + pr: "444444" + - title: Deprecated feature + type: deprecation + products: + - product: elasticsearch + target: 9.3.0 + description: Feature deprecated. + impact: Will be removed. + action: Use new feature. + pr: "555555" + """)); + + [Fact] + public void DefaultBehaviorExcludesSeparatedTypes() + { + Block!.TypeFilter.Should().Be(ChangelogTypeFilter.Default); + } + + [Fact] + public void DefaultBehaviorShowsFeatures() + { + Html.Should().Contain("Features and enhancements"); + Html.Should().Contain("New feature"); + } + + [Fact] + public void DefaultBehaviorShowsBugFixes() + { + Html.Should().Contain(">Fixes<"); + Html.Should().Contain("Bug fix"); + } + + [Fact] + public void DefaultBehaviorExcludesBreakingChanges() + { + Html.Should().NotContain("Breaking changes"); + Html.Should().NotContain("Breaking API change"); + } + + [Fact] + public void DefaultBehaviorExcludesKnownIssues() + { + Html.Should().NotContain("Known issues"); + Html.Should().NotContain("Known issue"); + } + + [Fact] + public void DefaultBehaviorExcludesDeprecations() + { + Html.Should().NotContain("Deprecations"); + Html.Should().NotContain("Deprecated feature"); + } +} + +/// +/// Tests for :type: all - shows all entry types including separated types. +/// +public class ChangelogTypeFilterAllTests : DirectiveTest +{ + public ChangelogTypeFilterAllTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: all + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: New feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Bug fix + type: bug-fix + products: + - product: elasticsearch + target: 9.3.0 + pr: "222222" + - title: Breaking API change + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: API changed. + impact: Users must update. + action: Follow guide. + pr: "333333" + - title: Known issue + type: known-issue + products: + - product: elasticsearch + target: 9.3.0 + description: Issue exists. + impact: Some impact. + action: Workaround available. + pr: "444444" + - title: Deprecated feature + type: deprecation + products: + - product: elasticsearch + target: 9.3.0 + description: Feature deprecated. + impact: Will be removed. + action: Use new feature. + pr: "555555" + """)); + + [Fact] + public void TypeFilterIsAll() + { + Block!.TypeFilter.Should().Be(ChangelogTypeFilter.All); + } + + [Fact] + public void ShowsAllEntryTypes() + { + Html.Should().Contain("Features and enhancements"); + Html.Should().Contain("New feature"); + Html.Should().Contain(">Fixes<"); + Html.Should().Contain("Bug fix"); + Html.Should().Contain("Breaking changes"); + Html.Should().Contain("Breaking API change"); + Html.Should().Contain("Known issues"); + Html.Should().Contain("Known issue"); + Html.Should().Contain("Deprecations"); + Html.Should().Contain("Deprecated feature"); + } +} + +/// +/// Tests for :type: breaking-change - shows only breaking changes. +/// +public class ChangelogTypeFilterBreakingChangeTests : DirectiveTest +{ + public ChangelogTypeFilterBreakingChangeTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: breaking-change + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: New feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Breaking API change + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: API changed. + impact: Users must update. + action: Follow guide. + pr: "333333" + - title: Known issue + type: known-issue + products: + - product: elasticsearch + target: 9.3.0 + description: Issue exists. + impact: Some impact. + action: Workaround available. + pr: "444444" + """)); + + [Fact] + public void TypeFilterIsBreakingChange() + { + Block!.TypeFilter.Should().Be(ChangelogTypeFilter.BreakingChange); + } + + [Fact] + public void ShowsBreakingChanges() + { + Html.Should().Contain("Breaking changes"); + Html.Should().Contain("Breaking API change"); + } + + [Fact] + public void ExcludesOtherTypes() + { + Html.Should().NotContain("Features and enhancements"); + Html.Should().NotContain("New feature"); + Html.Should().NotContain("Known issues"); + Html.Should().NotContain("Known issue"); + } +} + +/// +/// Tests for :type: deprecation - shows only deprecations. +/// +public class ChangelogTypeFilterDeprecationTests : DirectiveTest +{ + public ChangelogTypeFilterDeprecationTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: deprecation + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: New feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Deprecated feature + type: deprecation + products: + - product: elasticsearch + target: 9.3.0 + description: Feature deprecated. + impact: Will be removed. + action: Use new feature. + pr: "555555" + - title: Another deprecation + type: deprecation + products: + - product: elasticsearch + target: 9.3.0 + description: Another deprecated feature. + impact: Also will be removed. + action: Migrate to new API. + pr: "666666" + """)); + + [Fact] + public void TypeFilterIsDeprecation() + { + Block!.TypeFilter.Should().Be(ChangelogTypeFilter.Deprecation); + } + + [Fact] + public void ShowsDeprecations() + { + Html.Should().Contain("Deprecations"); + Html.Should().Contain("Deprecated feature"); + Html.Should().Contain("Another deprecation"); + } + + [Fact] + public void ExcludesOtherTypes() + { + Html.Should().NotContain("Features and enhancements"); + Html.Should().NotContain("New feature"); + } +} + +/// +/// Tests for :type: known-issue - shows only known issues. +/// +public class ChangelogTypeFilterKnownIssueTests : DirectiveTest +{ + public ChangelogTypeFilterKnownIssueTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: known-issue + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: New feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Known issue 1 + type: known-issue + products: + - product: elasticsearch + target: 9.3.0 + description: Issue exists. + impact: Some impact. + action: Workaround available. + pr: "444444" + - title: Known issue 2 + type: known-issue + products: + - product: elasticsearch + target: 9.3.0 + description: Another issue. + impact: Different impact. + action: Different workaround. + pr: "555555" + """)); + + [Fact] + public void TypeFilterIsKnownIssue() + { + Block!.TypeFilter.Should().Be(ChangelogTypeFilter.KnownIssue); + } + + [Fact] + public void ShowsKnownIssues() + { + Html.Should().Contain("Known issues"); + Html.Should().Contain("Known issue 1"); + Html.Should().Contain("Known issue 2"); + } + + [Fact] + public void ExcludesOtherTypes() + { + Html.Should().NotContain("Features and enhancements"); + Html.Should().NotContain("New feature"); + } +} + +/// +/// Tests for invalid :type: values - should emit warning and use default behavior. +/// +public class ChangelogTypeFilterInvalidTests : DirectiveTest +{ + public ChangelogTypeFilterInvalidTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: invalid-value + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: New feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Breaking change + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: Breaking. + impact: Impact. + action: Action. + pr: "222222" + """)); + + [Fact] + public void FallsBackToDefaultBehavior() + { + Block!.TypeFilter.Should().Be(ChangelogTypeFilter.Default); + } + + [Fact] + public void EmitsWarningForInvalidValue() + { + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid :type: value")); + } + + [Fact] + public void DefaultBehaviorIsApplied() + { + Html.Should().Contain("Features and enhancements"); + Html.Should().Contain("New feature"); + Html.Should().NotContain("Breaking changes"); + } +} + +/// +/// Tests for case-insensitive :type: values. +/// +public class ChangelogTypeFilterCaseInsensitiveTests : DirectiveTest +{ + public ChangelogTypeFilterCaseInsensitiveTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: ALL + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: New feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Breaking change + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: Breaking. + impact: Impact. + action: Action. + pr: "222222" + """)); + + [Fact] + public void AcceptsUppercaseAll() + { + Block!.TypeFilter.Should().Be(ChangelogTypeFilter.All); + } + + [Fact] + public void ShowsAllTypes() + { + Html.Should().Contain("Features and enhancements"); + Html.Should().Contain("Breaking changes"); + } +} + +/// +/// Tests for combining :type: with other options like :subsections:. +/// +public class ChangelogTypeFilterWithSubsectionsTests : DirectiveTest +{ + public ChangelogTypeFilterWithSubsectionsTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: all + :subsections: + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Search feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Search + pr: "111111" + - title: Indexing feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Indexing + pr: "222222" + - title: Breaking change + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: Breaking. + impact: Impact. + action: Action. + pr: "333333" + """)); + + [Fact] + public void TypeFilterAndSubsectionsBothWork() + { + Block!.TypeFilter.Should().Be(ChangelogTypeFilter.All); + Block!.Subsections.Should().BeTrue(); + } + + [Fact] + public void ShowsAllTypesWithSubsections() + { + Html.Should().Contain("Features and enhancements"); + Html.Should().Contain("Search feature"); + Html.Should().Contain("Indexing feature"); + Html.Should().Contain("Breaking changes"); + } +} + +/// +/// Tests that :type: filter affects generated anchors correctly. +/// +public class ChangelogTypeFilterGeneratedAnchorsTests : DirectiveTest +{ + public ChangelogTypeFilterGeneratedAnchorsTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: breaking-change + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: New feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Breaking change + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: Breaking. + impact: Impact. + action: Action. + pr: "222222" + """)); + + [Fact] + public void GeneratedAnchorsRespectTypeFilter() + { + var anchors = Block!.GeneratedAnchors.ToList(); + + // Should have anchor for breaking changes + anchors.Should().Contain(a => a.Contains("breaking-changes")); + + // Should NOT have anchor for features since we're filtering to breaking-change only + anchors.Should().NotContain(a => a.Contains("features-enhancements")); + } +} + +/// +/// Tests that :type: filter affects table of contents correctly. +/// +public class ChangelogTypeFilterTableOfContentsTests : DirectiveTest +{ + public ChangelogTypeFilterTableOfContentsTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: deprecation + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: New feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Deprecated feature + type: deprecation + products: + - product: elasticsearch + target: 9.3.0 + description: Deprecated. + impact: Impact. + action: Action. + pr: "222222" + """)); + + [Fact] + public void TableOfContentsRespectTypeFilter() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + + // Should have TOC item for deprecations + tocItems.Should().Contain(t => t.Heading == "Deprecations"); + + // Should NOT have TOC item for features since we're filtering to deprecation only + tocItems.Should().NotContain(t => t.Heading == "Features and enhancements"); + } +} + +/// +/// Tests that empty result shows appropriate message when type filter excludes all entries. +/// +public class ChangelogTypeFilterEmptyResultTests : DirectiveTest +{ + public ChangelogTypeFilterEmptyResultTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: known-issue + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: New feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + """)); + + [Fact] + public void ShowsNoEntriesMessageWhenFilterExcludesAll() + { + // When filtering to known-issue but bundle only has features, + // should show "no entries" message + Html.Should().Contain("No new features, enhancements, or fixes"); + } +} diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogVersionSortingTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogVersionSortingTests.cs new file mode 100644 index 000000000..2921ef8f6 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogVersionSortingTests.cs @@ -0,0 +1,275 @@ +// 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 System.IO.Abstractions.TestingHelpers; +using Elastic.Markdown.Myst.Directives.Changelog; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.Directives; + +public class ChangelogDateVersionedBundlesTests : DirectiveTest +{ + public ChangelogDateVersionedBundlesTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + // Create multiple bundles with date-based versions (Cloud Serverless style) + FileSystem.AddFile("docs/changelog/bundles/2025-08-01.yaml", new MockFileData( + // language=yaml + """ + products: + - product: cloud-serverless + target: 2025-08-01 + entries: + - title: August 1st feature + type: feature + products: + - product: cloud-serverless + target: 2025-08-01 + pr: "111111" + """)); + + FileSystem.AddFile("docs/changelog/bundles/2025-08-15.yaml", new MockFileData( + // language=yaml + """ + products: + - product: cloud-serverless + target: 2025-08-15 + entries: + - title: August 15th feature + type: feature + products: + - product: cloud-serverless + target: 2025-08-15 + pr: "222222" + """)); + + FileSystem.AddFile("docs/changelog/bundles/2025-08-05.yaml", new MockFileData( + // language=yaml + """ + products: + - product: cloud-serverless + target: 2025-08-05 + entries: + - title: August 5th feature + type: feature + products: + - product: cloud-serverless + target: 2025-08-05 + pr: "333333" + """)); + } + + [Fact] + public void LoadsBundles() => Block!.LoadedBundles.Should().HaveCount(3); + + [Fact] + public void RendersInDateOrderDescending() + { + // Should be sorted by date descending: 2025-08-15 > 2025-08-05 > 2025-08-01 + var idx15 = Html.IndexOf("2025-08-15", StringComparison.Ordinal); + var idx05 = Html.IndexOf("2025-08-05", StringComparison.Ordinal); + var idx01 = Html.IndexOf("2025-08-01", StringComparison.Ordinal); + + idx15.Should().BeLessThan(idx05, "2025-08-15 should appear before 2025-08-05"); + idx05.Should().BeLessThan(idx01, "2025-08-05 should appear before 2025-08-01"); + } + + [Fact] + public void RendersAllDateVersions() + { + Html.Should().Contain("2025-08-15"); + Html.Should().Contain("2025-08-05"); + Html.Should().Contain("2025-08-01"); + } + + [Fact] + public void RendersEntriesForDateVersions() + { + Html.Should().Contain("August 15th feature"); + Html.Should().Contain("August 5th feature"); + Html.Should().Contain("August 1st feature"); + } +} + +public class ChangelogMixedVersionTypesTests : DirectiveTest +{ + public ChangelogMixedVersionTypesTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + // Create bundles with mixed version types (semver and dates) + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Semver 9.3.0 feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + """)); + + FileSystem.AddFile("docs/changelog/bundles/9.2.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.2.0 + entries: + - title: Semver 9.2.0 feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + pr: "222222" + """)); + + FileSystem.AddFile("docs/changelog/bundles/2025-08-05.yaml", new MockFileData( + // language=yaml + """ + products: + - product: cloud-serverless + target: 2025-08-05 + entries: + - title: Date-based feature + type: feature + products: + - product: cloud-serverless + target: 2025-08-05 + pr: "333333" + """)); + + FileSystem.AddFile("docs/changelog/bundles/2025-07-01.yaml", new MockFileData( + // language=yaml + """ + products: + - product: cloud-serverless + target: 2025-07-01 + entries: + - title: Earlier date feature + type: feature + products: + - product: cloud-serverless + target: 2025-07-01 + pr: "444444" + """)); + } + + [Fact] + public void LoadsAllBundles() => Block!.LoadedBundles.Should().HaveCount(4); + + [Fact] + public void SemverVersionsAppearBeforeDates() + { + // Semver versions should appear before date versions + var idx93 = Html.IndexOf("9.3.0", StringComparison.Ordinal); + var idx92 = Html.IndexOf("9.2.0", StringComparison.Ordinal); + var idxDate1 = Html.IndexOf("2025-08-05", StringComparison.Ordinal); + var idxDate2 = Html.IndexOf("2025-07-01", StringComparison.Ordinal); + + // Semver versions should come before dates + idx93.Should().BeLessThan(idxDate1, "Semver 9.3.0 should appear before date 2025-08-05"); + idx92.Should().BeLessThan(idxDate1, "Semver 9.2.0 should appear before date 2025-08-05"); + + // Within semver, should be sorted correctly + idx93.Should().BeLessThan(idx92, "9.3.0 should appear before 9.2.0"); + + // Within dates, should be sorted correctly (descending) + idxDate1.Should().BeLessThan(idxDate2, "2025-08-05 should appear before 2025-07-01"); + } + + [Fact] + public void RendersAllVersions() + { + Html.Should().Contain("9.3.0"); + Html.Should().Contain("9.2.0"); + Html.Should().Contain("2025-08-05"); + Html.Should().Contain("2025-07-01"); + } + + [Fact] + public void RendersAllEntries() + { + Html.Should().Contain("Semver 9.3.0 feature"); + Html.Should().Contain("Semver 9.2.0 feature"); + Html.Should().Contain("Date-based feature"); + Html.Should().Contain("Earlier date feature"); + } +} + +public class ChangelogRawVersionFallbackTests : DirectiveTest +{ + public ChangelogRawVersionFallbackTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + // Create bundles with non-standard version formats (edge case) + FileSystem.AddFile("docs/changelog/bundles/release-alpha.yaml", new MockFileData( + // language=yaml + """ + products: + - product: experimental + target: release-alpha + entries: + - title: Alpha release feature + type: feature + products: + - product: experimental + target: release-alpha + pr: "111111" + """)); + + FileSystem.AddFile("docs/changelog/bundles/release-beta.yaml", new MockFileData( + // language=yaml + """ + products: + - product: experimental + target: release-beta + entries: + - title: Beta release feature + type: feature + products: + - product: experimental + target: release-beta + pr: "222222" + """)); + } + + [Fact] + public void LoadsBundles() => Block!.LoadedBundles.Should().HaveCount(2); + + [Fact] + public void RendersNonStandardVersions() + { + // Both non-standard versions should be rendered (sorted lexicographically) + Html.Should().Contain("release-alpha"); + Html.Should().Contain("release-beta"); + Html.Should().Contain("Alpha release feature"); + Html.Should().Contain("Beta release feature"); + } + + [Fact] + public void SortsLexicographically() + { + // "release-beta" > "release-alpha" lexicographically + var idxBeta = Html.IndexOf("release-beta", StringComparison.Ordinal); + var idxAlpha = Html.IndexOf("release-alpha", StringComparison.Ordinal); + + idxBeta.Should().BeLessThan(idxAlpha, "release-beta should appear before release-alpha (descending lexicographic)"); + } +}