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)");
+ }
+}