diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 99fae06718..1e089f1443 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -4530,6 +4530,15 @@ "summary": "Optional: Group entries by area/component in subsections. For breaking changes with a subtype, groups by subtype instead of area. Defaults to false", "defaultValue": "false" }, + { + "role": "flag", + "name": "dropdowns", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: Render separated types (breaking changes, deprecations, known issues, highlights) as MyST dropdowns. When false (default), renders as flattened bulleted lists. Defaults to false", + "defaultValue": "false" + }, { "role": "flag", "name": "title", diff --git a/docs/cli/changelog/cmd-render.md b/docs/cli/changelog/cmd-render.md index e02c565e3d..ffd314a2b0 100644 --- a/docs/cli/changelog/cmd-render.md +++ b/docs/cli/changelog/cmd-render.md @@ -9,6 +9,14 @@ The `render` command automatically discovers and merges `.amend-*.yaml` files wi The `changelog render` command does **not** use `rules.publish` for filtering. Filtering must be done at bundle time using `rules.bundle`. For how the directive differs, see the [{changelog} directive syntax reference](/syntax/changelog.md). +## Options + +: `--dropdowns` + Render separated types (breaking changes, deprecations, known issues, highlights) as MyST dropdowns. Defaults to false (flattened bulleted lists). When used, each entry in separated files is rendered as a collapsible dropdown section using MyST syntax (`::::{dropdown}`). When it's not used, entries are rendered as flattened bulleted lists with PR/issue links inline and `Impact` and `Action` sections indented. This flag affects only markdown output; AsciiDoc output always uses its standard format. + +: `--title` + The title to use for section headers, directories, and anchors in output files. Defaults to the version in the first bundle. When omitted, ISO date targets are formatted for display the same way as the `{changelog}` directive (for example, `2026-05-04` becomes "May 4, 2026", `2026-05` becomes "May 2026"), while directory names and heading anchors continue to use the raw target slug. If the string contains spaces, they are replaced with dashes when used in directory names and anchors. + ## Output formats ### Markdown @@ -23,7 +31,26 @@ The default output (`--file-type markdown`) generates multiple files: ### Asciidoc -`--file-type asciidoc` generates a single file with all sections in order: security updates, bug fixes, highlights, new features and enhancements, breaking changes, deprecations, known issues, documentation, regressions, and other changes. The asciidoc output uses attribute references for links (for example, `{repo-pull}NUMBER[#NUMBER]`). +When `--file-type asciidoc` is specified, the command generates a single asciidoc file with all sections: + +- Security updates +- Bug fixes +- Highlights (only included when at least one entry has `highlight: true`) +- New features and enhancements +- Breaking changes +- Deprecations +- Known issues +- Documentation +- Regressions +- Other changes + +The asciidoc output uses attribute references for links (for example, `{repo-pull}NUMBER[#NUMBER]`). + +AsciiDoc output ignores the `--dropdowns` flag and always uses a standardized format with the following characteristics: + +- Multi-block entries (containing description, Impact, and Action sections) use proper list continuation markers (`+`) to maintain list structure +- Strong text formatting uses idiomatic single asterisk syntax (`*Impact:*`, `*Action:*`) following AsciiDoc best practices +- All content blocks are properly attached to their parent list items for correct rendering. ### Multiple PR and issue links @@ -55,4 +82,16 @@ docs-builder changelog render \ docs-builder changelog render \ --input "./public-bundle.yaml|./changelog|elasticsearch|keep-links,./private-bundle.yaml|./private-changelog|internal-repo|hide-links" \ --output ./release-notes + +# Render with subsections and flattened format (default) +docs-builder changelog render \ + --input "./docs/changelog/bundles/9.3.0.yaml" \ + --output ./release-notes \ + --subsections + +### Render with dropdown format +docs-builder changelog render \ + --input "./docs/changelog/bundles/9.3.0.yaml" \ + --output ./release-notes \ + --dropdowns ``` diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs index 92a6e23f0e..d9aa5fcb6c 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs @@ -61,40 +61,48 @@ private static void RenderEntryTitleAndLinks(StringBuilder sb, ChangelogEntry en } /// - /// Renders an entry's description with optional comment handling + /// Renders an entry's description with optional comment handling and list continuation /// - private static void RenderEntryDescription(StringBuilder sb, ChangelogEntry entry, bool shouldHide) + private static void RenderEntryDescription(StringBuilder sb, ChangelogEntry entry, bool shouldHide, bool needsContinuation = true) { if (string.IsNullOrWhiteSpace(entry.Description)) return; _ = sb.AppendLine(); - var indented = ChangelogTextUtilities.Indent(entry.Description); + + // Add list continuation marker for multi-block list items + if (needsContinuation) + { + _ = sb.AppendLine("+"); + } + if (shouldHide) { - var indentedLines = indented.Split('\n'); - foreach (var line in indentedLines) + var descriptionLines = entry.Description.Split('\n'); + foreach (var line in descriptionLines) _ = sb.AppendLine(CultureInfo.InvariantCulture, $"// {line}"); } else - _ = sb.AppendLine(indented); + _ = sb.AppendLine(entry.Description); } /// - /// Renders Impact and Action fields for breaking changes, deprecations, and known issues + /// Renders Impact and Action fields for breaking changes, deprecations, and known issues with list continuation /// private static void RenderImpactAndAction(StringBuilder sb, ChangelogEntry entry) { if (!string.IsNullOrWhiteSpace(entry.Impact)) { _ = sb.AppendLine(); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"**Impact:** {entry.Impact}"); + _ = sb.AppendLine("+"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"*Impact:* {entry.Impact}"); } if (!string.IsNullOrWhiteSpace(entry.Action)) { _ = sb.AppendLine(); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"**Action:** {entry.Action}"); + _ = sb.AppendLine("+"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"*Action:* {entry.Action}"); } } @@ -105,7 +113,7 @@ protected void RenderBasicEntry(StringBuilder sb, ChangelogEntry entry, Changelo { var (entryRepo, _, hideLinks, shouldHide) = ChangelogRenderUtilities.GetEntryContext(entry, context); RenderEntryTitleAndLinks(sb, entry, entryRepo, hideLinks, shouldHide); - RenderEntryDescription(sb, entry, shouldHide); + RenderEntryDescription(sb, entry, shouldHide, needsContinuation: !string.IsNullOrWhiteSpace(entry.Description)); _ = sb.AppendLine(); } @@ -116,7 +124,11 @@ protected void RenderEntryWithImpactAction(StringBuilder sb, ChangelogEntry entr { var (entryRepo, _, hideLinks, shouldHide) = ChangelogRenderUtilities.GetEntryContext(entry, context); RenderEntryTitleAndLinks(sb, entry, entryRepo, hideLinks, shouldHide); - RenderEntryDescription(sb, entry, shouldHide); + + // Description needs continuation when it exists + var hasDescription = !string.IsNullOrWhiteSpace(entry.Description); + RenderEntryDescription(sb, entry, shouldHide, needsContinuation: hasDescription); + RenderImpactAndAction(sb, entry); _ = sb.AppendLine(); } diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/BreakingChangesAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/BreakingChangesAsciidocRenderer.cs index cdf2a794de..99532acf38 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/BreakingChangesAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/BreakingChangesAsciidocRenderer.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Globalization; using System.Text; using Elastic.Documentation; using Elastic.Documentation.ReleaseNotes; @@ -30,8 +31,17 @@ public override void Render(IReadOnlyCollection entries, Changel if (context.Subsections && !string.IsNullOrWhiteSpace(group.Key)) { var header = ChangelogTextUtilities.FormatSubtypeHeader(group.Key); - var headerLine = allEntriesHidden ? $"// **{header}**" : $"**{header}**"; - _ = sb.AppendLine(headerLine); + + if (allEntriesHidden) + { + _ = sb.AppendLine("// [float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"// ==== {header}"); + } + else + { + _ = sb.AppendLine("[float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"==== {header}"); + } _ = sb.AppendLine(); } diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs index 11d1cf7c06..2800e9507f 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Globalization; using System.Text; using Elastic.Documentation.ReleaseNotes; @@ -15,22 +16,39 @@ public class DeprecationsAsciidocRenderer(StringBuilder sb) : AsciidocRendererBa /// public override void Render(IReadOnlyCollection entries, ChangelogRenderContext context) { - var groupedByArea = entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList(); + // Group by area if subsections is enabled, otherwise use single group + if (entries.Count == 0) + return; + var groupedEntries = context.Subsections + ? entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList() + : [entries.GroupBy(_ => string.Empty).First()]; - foreach (var areaGroup in groupedByArea) + foreach (var group in groupedEntries) { - // Check if all entries in this area group are hidden - var allEntriesHidden = areaGroup.All(entry => + // Check if all entries in this group are hidden + var allEntriesHidden = group.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); - var componentName = !string.IsNullOrWhiteSpace(areaGroup.Key) ? areaGroup.Key : "General"; - var formattedComponent = ChangelogTextUtilities.FormatAreaHeader(componentName); + // Add nested section header when subsections are enabled and group has a name + if (context.Subsections && !string.IsNullOrWhiteSpace(group.Key)) + { + var componentName = group.Key != string.Empty ? group.Key : "General"; + var formattedComponent = ChangelogTextUtilities.FormatAreaHeader(componentName); - var headerLine = allEntriesHidden ? $"// {formattedComponent}::" : $"{formattedComponent}::"; - _ = sb.AppendLine(headerLine); - _ = sb.AppendLine(); + if (allEntriesHidden) + { + _ = sb.AppendLine("// [float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"// ==== {formattedComponent}"); + } + else + { + _ = sb.AppendLine("[float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"==== {formattedComponent}"); + } + _ = sb.AppendLine(); + } - foreach (var entry in areaGroup) + foreach (var entry in group) RenderEntryWithImpactAction(sb, entry, context); } } diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/EntriesByAreaAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/EntriesByAreaAsciidocRenderer.cs index 0bd171ad27..513f8901ba 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/EntriesByAreaAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/EntriesByAreaAsciidocRenderer.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Globalization; using System.Text; using Elastic.Documentation.ReleaseNotes; @@ -15,24 +16,37 @@ public class EntriesByAreaAsciidocRenderer(StringBuilder sb) : AsciidocRendererB /// public override void Render(IReadOnlyCollection entries, ChangelogRenderContext context) { - var groupedByArea = context.Subsections + // Group by area if subsections is enabled, otherwise use single group + var groupedEntries = context.Subsections ? entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList() - : entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).ToList(); + : [entries.GroupBy(_ => string.Empty).First()]; - foreach (var areaGroup in groupedByArea) + foreach (var group in groupedEntries) { - // Check if all entries in this area group are hidden - var allEntriesHidden = areaGroup.All(entry => + // Check if all entries in this group are hidden + var allEntriesHidden = group.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); - var componentName = !string.IsNullOrWhiteSpace(areaGroup.Key) ? areaGroup.Key : "General"; - var formattedComponent = ChangelogTextUtilities.FormatAreaHeader(componentName); + // Add nested section header when subsections are enabled and group has a name + if (context.Subsections && !string.IsNullOrWhiteSpace(group.Key)) + { + var componentName = group.Key != string.Empty ? group.Key : "General"; + var formattedComponent = ChangelogTextUtilities.FormatAreaHeader(componentName); - var headerLine = allEntriesHidden ? $"// {formattedComponent}::" : $"{formattedComponent}::"; - _ = sb.AppendLine(headerLine); - _ = sb.AppendLine(); + if (allEntriesHidden) + { + _ = sb.AppendLine("// [float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"// ==== {formattedComponent}"); + } + else + { + _ = sb.AppendLine("[float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"==== {formattedComponent}"); + } + _ = sb.AppendLine(); + } - foreach (var entry in areaGroup) + foreach (var entry in group) RenderBasicEntry(sb, entry, context); } } diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/HighlightsAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/HighlightsAsciidocRenderer.cs index eff5542d09..07312f14f4 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/HighlightsAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/HighlightsAsciidocRenderer.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Globalization; using System.Text; using Elastic.Documentation.ReleaseNotes; @@ -15,24 +16,37 @@ public class HighlightsAsciidocRenderer(StringBuilder sb) : AsciidocRendererBase /// public override void Render(IReadOnlyCollection entries, ChangelogRenderContext context) { - var groupedByArea = context.Subsections + // Group by area if subsections is enabled, otherwise use single group + var groupedEntries = context.Subsections ? entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList() - : entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).ToList(); + : [entries.GroupBy(_ => string.Empty).First()]; - foreach (var areaGroup in groupedByArea) + foreach (var group in groupedEntries) { - // Check if all entries in this area group are hidden - var allEntriesHidden = areaGroup.All(entry => + // Check if all entries in this group are hidden + var allEntriesHidden = group.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); - var componentName = !string.IsNullOrWhiteSpace(areaGroup.Key) ? areaGroup.Key : "General"; - var formattedComponent = ChangelogTextUtilities.FormatAreaHeader(componentName); + // Add nested section header when subsections are enabled and group has a name + if (context.Subsections && !string.IsNullOrWhiteSpace(group.Key)) + { + var componentName = group.Key != string.Empty ? group.Key : "General"; + var formattedComponent = ChangelogTextUtilities.FormatAreaHeader(componentName); - var headerLine = allEntriesHidden ? $"// {formattedComponent}::" : $"{formattedComponent}::"; - _ = sb.AppendLine(headerLine); - _ = sb.AppendLine(); + if (allEntriesHidden) + { + _ = sb.AppendLine("// [float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"// ==== {formattedComponent}"); + } + else + { + _ = sb.AppendLine("[float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"==== {formattedComponent}"); + } + _ = sb.AppendLine(); + } - foreach (var entry in areaGroup) + foreach (var entry in group) RenderBasicEntry(sb, entry, context); } } diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/KnownIssuesAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/KnownIssuesAsciidocRenderer.cs index f7ff3bc346..74fac4e4f9 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/KnownIssuesAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/KnownIssuesAsciidocRenderer.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Globalization; using System.Text; using Elastic.Documentation.ReleaseNotes; @@ -15,22 +16,37 @@ public class KnownIssuesAsciidocRenderer(StringBuilder sb) : AsciidocRendererBas /// public override void Render(IReadOnlyCollection entries, ChangelogRenderContext context) { - var groupedByArea = entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList(); + // Group by area if subsections is enabled, otherwise use single group + var groupedEntries = context.Subsections + ? entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList() + : [entries.GroupBy(_ => string.Empty).First()]; - foreach (var areaGroup in groupedByArea) + foreach (var group in groupedEntries) { - // Check if all entries in this area group are hidden - var allEntriesHidden = areaGroup.All(entry => + // Check if all entries in this group are hidden + var allEntriesHidden = group.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); - var componentName = !string.IsNullOrWhiteSpace(areaGroup.Key) ? areaGroup.Key : "General"; - var formattedComponent = ChangelogTextUtilities.FormatAreaHeader(componentName); + // Add nested section header when subsections are enabled and group has a name + if (context.Subsections && !string.IsNullOrWhiteSpace(group.Key)) + { + var componentName = group.Key != string.Empty ? group.Key : "General"; + var formattedComponent = ChangelogTextUtilities.FormatAreaHeader(componentName); - var headerLine = allEntriesHidden ? $"// {formattedComponent}::" : $"{formattedComponent}::"; - _ = sb.AppendLine(headerLine); - _ = sb.AppendLine(); + if (allEntriesHidden) + { + _ = sb.AppendLine("// [float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"// ==== {formattedComponent}"); + } + else + { + _ = sb.AppendLine("[float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"==== {formattedComponent}"); + } + _ = sb.AppendLine(); + } - foreach (var entry in areaGroup) + foreach (var entry in group) RenderEntryWithImpactAction(sb, entry, context); } } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs index 4fcdfbe203..19a949aa76 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs @@ -20,6 +20,7 @@ public record ChangelogRenderContext public required string Owner { get; init; } public required IReadOnlyDictionary> EntriesByType { get; init; } public required bool Subsections { get; init; } + public required bool Dropdowns { get; init; } public required HashSet FeatureIdsToHide { get; init; } public required Dictionary> EntryToBundleProducts { get; init; } public required Dictionary EntryToRepo { get; init; } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 6d394a904a..068212a72a 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -28,6 +28,7 @@ public record RenderChangelogsArguments public string? Output { get; init; } public string? Title { get; init; } public bool Subsections { get; init; } + public bool Dropdowns { get; init; } public string[]? HideFeatures { get; init; } public string? Config { get; init; } public ChangelogFileType FileType { get; init; } = ChangelogFileType.Markdown; @@ -226,9 +227,23 @@ private OutputSetup SetupOutput( if (string.IsNullOrWhiteSpace(input.Title) && version == "unknown") collector.EmitWarning(string.Empty, "No --title option provided and bundle files do not contain 'target' values. Output folder and markdown titles will default to 'unknown'. Consider using --title to specify a custom title."); - // Use title from input or default to version - var title = input.Title ?? version; - var titleSlug = ChangelogTextUtilities.TitleToSlug(title); + // Determine title and slug + string title; + string titleSlug; + + if (string.IsNullOrWhiteSpace(input.Title)) + { + // Default title: format dates like the changelog directive + title = VersionOrDate.FormatDisplayVersion(version); + // Slug always uses raw version to maintain consistent paths/anchors + titleSlug = ChangelogTextUtilities.TitleToSlug(version); + } + else + { + // Explicit title provided: use as-is for both title and slug + title = input.Title; + titleSlug = ChangelogTextUtilities.TitleToSlug(input.Title); + } return new OutputSetup(outputDir, title, titleSlug); } @@ -325,6 +340,7 @@ private static ChangelogRenderContext BuildRenderContext( Owner = ownerForAnchors, EntriesByType = entriesByType, Subsections = input.Subsections, + Dropdowns = input.Dropdowns, FeatureIdsToHide = featureIdsToHide, EntryToBundleProducts = entryToBundleProducts, EntryToRepo = entryToRepo, diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs index 7cace43f70..4cee9acece 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs @@ -63,22 +63,58 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); if (shouldHide) _ = sb.AppendLine(""); } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs index 78dcf5fbd5..c5b80228b4 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs @@ -60,22 +60,58 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); if (shouldHide) _ = sb.AppendLine(""); } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs index f55a3c19a3..5d5cf5a2e7 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs @@ -63,11 +63,34 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); if (shouldHide) _ = sb.AppendLine(""); } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs index a8640f14b3..3f6e3b22da 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs @@ -60,22 +60,58 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); if (shouldHide) _ = sb.AppendLine(""); } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs index 6e7cc94473..d545d6e502 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs @@ -10,6 +10,17 @@ namespace Elastic.Changelog.Rendering.Markdown; +/// +/// Options for rendering PR and issue links +/// +public record PrIssueLinkOptions( + ChangelogEntry Entry, + string Repo, + string Owner, + bool HideLinks, + bool IndentForListItem = false +); + /// /// Abstract base class for changelog markdown renderers /// @@ -37,22 +48,22 @@ protected async Task WriteOutputFileAsync(string outputDir, string titleSlug, st } /// - /// Renders PR and issue links for dropdown entries + /// Renders PR and issue links with configurable formatting options /// - protected static void RenderPrIssueLinks(StringBuilder sb, ChangelogEntry entry, string entryRepo, string entryOwner, bool entryHideLinks) + protected static void RenderPrIssueLinks(StringBuilder sb, PrIssueLinkOptions options) { var prParts = new List(); - foreach (var pr in entry.Prs ?? []) + foreach (var pr in options.Entry.Prs ?? []) { - var s = ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner); + var s = ChangelogTextUtilities.FormatPrLink(pr, options.Repo, options.HideLinks, options.Owner); if (!string.IsNullOrEmpty(s)) prParts.Add(s); } var issueParts = new List(); - foreach (var issue in entry.Issues ?? []) + foreach (var issue in options.Entry.Issues ?? []) { - var s = ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner); + var s = ChangelogTextUtilities.FormatIssueLink(issue, options.Repo, options.HideLinks, options.Owner); if (!string.IsNullOrEmpty(s)) issueParts.Add(s); } @@ -60,36 +71,30 @@ protected static void RenderPrIssueLinks(StringBuilder sb, ChangelogEntry entry, if (prParts.Count == 0 && issueParts.Count == 0) return; - if (entryHideLinks) - { - foreach (var s in prParts) - _ = sb.AppendLine(s); - foreach (var s in issueParts) - _ = sb.AppendLine(s); - - _ = sb.AppendLine("For more information, check the pull request or issue above."); - } - else + if (options.HideLinks) { - _ = sb.Append("For more information, check "); - var first = true; foreach (var s in prParts) { - if (!first) - _ = sb.Append(' '); - _ = sb.Append(s); - first = false; + var line = options.IndentForListItem ? ChangelogTextUtilities.Indent(s) : s; + _ = sb.AppendLine(line); } - foreach (var s in issueParts) { - if (!first) - _ = sb.Append(' '); - _ = sb.Append(s); - first = false; + var line = options.IndentForListItem ? ChangelogTextUtilities.Indent(s) : s; + _ = sb.AppendLine(line); } - _ = sb.AppendLine("."); + var infoLine = "For more information, check the pull request or issue above."; + _ = sb.AppendLine(options.IndentForListItem ? ChangelogTextUtilities.Indent(infoLine) : infoLine); + } + else + { + var lineParts = new List { "For more information, check" }; + lineParts.AddRange(prParts); + lineParts.AddRange(issueParts); + + var fullLine = string.Join(" ", lineParts) + "."; + _ = sb.AppendLine(options.IndentForListItem ? ChangelogTextUtilities.Indent(fullLine) : fullLine); } _ = sb.AppendLine(); diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 2d87ef40b7..3b023cf912 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -1149,6 +1149,7 @@ async static (s, collector, state, ctx) => await s.RemoveChangelogs(collector, s /// Filter by feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. Entries with matching feature-id values will be commented out in the output. /// Optional: Output directory for rendered files. Defaults to current directory /// Optional: Group entries by area/component in subsections. For breaking changes with a subtype, groups by subtype instead of area. Defaults to false + /// Optional: Render separated types (breaking changes, deprecations, known issues, highlights) as MyST dropdowns. When false (default), renders as flattened bulleted lists. Defaults to false /// Optional: Title to use for section headers in output files. Defaults to version from first bundle /// [NoOptionsInjection] @@ -1159,6 +1160,7 @@ public async Task Render( string[]? hideFeatures = null, string? output = null, bool subsections = false, + bool dropdowns = false, string? title = null, CancellationToken ct = default ) @@ -1191,6 +1193,7 @@ public async Task Render( Output = output, Title = title, Subsections = subsections, + Dropdowns = dropdowns, HideFeatures = allFeatureIds.Count > 0 ? allFeatureIds.ToArray() : null, FileType = ft.Value, Config = config?.FullName diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs new file mode 100644 index 0000000000..204fe7c0d1 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs @@ -0,0 +1,429 @@ +// 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; +using AwesomeAssertions; +using Elastic.Changelog.Bundling; +using Elastic.Changelog.Rendering; +using Elastic.Documentation.Configuration; + +namespace Elastic.Changelog.Tests.Changelogs.Render; + +public class DropdownRenderTests(ITestOutputHelper output) : RenderChangelogTestBase(output) +{ + [Fact] + public async Task RenderChangelogs_WithDropdownsTrue_RendersDropdownFormat() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create breaking change changelog + // language=yaml + var breakingChange = + """ + title: Breaking API change + type: breaking-change + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "123" + description: API has been changed to improve performance + impact: Existing API calls will fail + action: Update your code to use the new API endpoints + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "breaking-change.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, breakingChange, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: breaking-change.yaml + checksum: {ComputeSha1(breakingChange)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0", + Dropdowns = true + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var breakingChangesFile = FileSystem.Path.Join(outputDir, "9.2.0", "breaking-changes.md"); + FileSystem.File.Exists(breakingChangesFile).Should().BeTrue(); + + var content = await FileSystem.File.ReadAllTextAsync(breakingChangesFile, TestContext.Current.CancellationToken); + + // Verify dropdown format + content.Should().Contain("::::{dropdown} Breaking API change"); + content.Should().Contain("API has been changed to improve performance"); + content.Should().Contain("**Impact**
Existing API calls will fail"); + content.Should().Contain("**Action**
Update your code to use the new API endpoints"); + content.Should().Contain("::::"); + } + + [Fact] + public async Task RenderChangelogs_WithDropdownsFalse_RendersFlattendFormat() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create deprecation changelog + // language=yaml + var deprecation = + """ + title: Deprecated old API + type: deprecation + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "456" + issues: + - "789" + description: The old API is deprecated + impact: API will be removed in future version + action: Migrate to the new API + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "deprecation.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, deprecation, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: deprecation.yaml + checksum: {ComputeSha1(deprecation)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0", + Dropdowns = false // Explicitly set to false for clarity + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var deprecationsFile = FileSystem.Path.Join(outputDir, "9.2.0", "deprecations.md"); + FileSystem.File.Exists(deprecationsFile).Should().BeTrue(); + + var content = await FileSystem.File.ReadAllTextAsync(deprecationsFile, TestContext.Current.CancellationToken); + + // Verify flattened format + content.Should().Contain("* Deprecated old API"); + content.Should().Contain("The old API is deprecated"); + content.Should().Contain(" For more information, check"); // Indented for list continuation + content.Should().Contain("#456"); + content.Should().Contain("#789"); + content.Should().Contain(" **Impact:** API will be removed in future version"); // Indented for list continuation + content.Should().Contain(" **Action:** Migrate to the new API"); // Indented for list continuation + + // Should NOT contain dropdown syntax + content.Should().NotContain("::::{dropdown}"); + content.Should().NotContain("::::"); + } + + [Fact] + public async Task RenderChangelogs_DefaultDropdownsFalse_RendersFlattedFormat() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create known issue changelog + // language=yaml + var knownIssue = + """ + title: Known issue with search + type: known-issue + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "999" + description: Search results are incomplete under certain conditions + impact: Some search results may be missing + action: Use the workaround provided in the documentation + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "known-issue.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, knownIssue, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: known-issue.yaml + checksum: {ComputeSha1(knownIssue)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0" + // Note: Dropdowns not set, should default to false + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var knownIssuesFile = FileSystem.Path.Join(outputDir, "9.2.0", "known-issues.md"); + FileSystem.File.Exists(knownIssuesFile).Should().BeTrue(); + + var content = await FileSystem.File.ReadAllTextAsync(knownIssuesFile, TestContext.Current.CancellationToken); + + // Verify flattened format (default behavior) + content.Should().Contain("* Known issue with search"); + content.Should().Contain("Search results are incomplete under certain conditions"); + content.Should().Contain(" For more information, check"); // Indented for list continuation + content.Should().Contain("#999"); + content.Should().Contain(" **Impact:** Some search results may be missing"); // Indented for list continuation + content.Should().Contain(" **Action:** Use the workaround provided in the documentation"); // Indented for list continuation + + // Should NOT contain dropdown syntax + content.Should().NotContain("::::{dropdown}"); + content.Should().NotContain("::::"); + } + + [Fact] + public async Task RenderChangelogs_HighlightsWithDropdowns_RendersCorrectFormat() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create highlight feature + // language=yaml + var highlight = + """ + title: Amazing new feature + type: feature + highlight: true + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "555" + description: This feature revolutionizes how you work with data + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "highlight.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, highlight, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: highlight.yaml + checksum: {ComputeSha1(highlight)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + // Test both dropdown and flattened modes + var testCases = new[] + { + new { Dropdowns = true, ExpectDropdown = true }, + new { Dropdowns = false, ExpectDropdown = false } + }; + + foreach (var testCase in testCases) + { + var subOutputDir = FileSystem.Path.Join(outputDir, testCase.Dropdowns.ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = subOutputDir, + Title = "9.2.0", + Dropdowns = testCase.Dropdowns + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var highlightsFile = FileSystem.Path.Join(subOutputDir, "9.2.0", "highlights.md"); + FileSystem.File.Exists(highlightsFile).Should().BeTrue(); + + var content = await FileSystem.File.ReadAllTextAsync(highlightsFile, TestContext.Current.CancellationToken); + + if (testCase.ExpectDropdown) + { + // Verify dropdown format + content.Should().Contain("::::{dropdown} Amazing new feature"); + content.Should().Contain("This feature revolutionizes how you work with data"); + content.Should().Contain("::::"); + } + else + { + // Verify flattened format + content.Should().Contain("* Amazing new feature"); + content.Should().Contain("This feature revolutionizes how you work with data"); + content.Should().Contain(" For more information, check"); // Indented for list continuation + content.Should().Contain("#555"); + + // Should NOT contain dropdown syntax + content.Should().NotContain("::::{dropdown}"); + content.Should().NotContain("::::"); + } + } + } + + [Fact] + public async Task RenderChangelogs_AsciidocFormat_IgnoresDropdownsFlag() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create breaking change + // language=yaml + var breakingChange = + """ + title: Breaking API change + type: breaking-change + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "123" + description: API has been changed + impact: Existing API calls will fail + action: Update your code + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "breaking-change.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, breakingChange, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: breaking-change.yaml + checksum: {ComputeSha1(breakingChange)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + // Test both dropdown values with AsciiDoc format + var testCases = new[] { true, false }; + + foreach (var dropdowns in testCases) + { + var subOutputDir = FileSystem.Path.Join(outputDir, dropdowns.ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = subOutputDir, + Title = "9.2.0", + Dropdowns = dropdowns, + FileType = ChangelogFileType.Asciidoc + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + // Find the AsciiDoc file (path structure differs from Markdown) + var asciidocFiles = FileSystem.Directory.GetFiles(subOutputDir, "*.asciidoc", SearchOption.AllDirectories); + asciidocFiles.Should().HaveCount(1, "should create exactly one AsciiDoc file"); + + var asciidocFile = asciidocFiles[0]; + var content = await FileSystem.File.ReadAllTextAsync(asciidocFile, TestContext.Current.CancellationToken); + + // AsciiDoc should always use bullet format regardless of dropdowns flag + content.Should().Contain("* Breaking API change"); + content.Should().Contain("*Impact:* Existing API calls will fail"); + content.Should().Contain("*Action:* Update your code"); + + // Should never contain MyST dropdown syntax + content.Should().NotContain("::::{dropdown}"); + content.Should().NotContain("::::"); + } + } +} diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/TitleTargetTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/TitleTargetTests.cs index b6bf4c5767..71b3fa53c0 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/TitleTargetTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/TitleTargetTests.cs @@ -132,4 +132,202 @@ public async Task RenderChangelogs_WithTitleAndNoTargets_NoWarning() d.Severity == Severity.Warning && d.Message.Contains("No --title option provided")); } + + [Fact] + public async Task RenderChangelogs_WithIsoDateTarget_FormatsDateInHeading() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file with ISO date target + // language=yaml + var changelog1 = + """ + title: Test feature + type: feature + products: + - product: elasticsearch + target: 2026-05-04 + prs: + - "100" + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "1755268130-test-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); + + // Create bundle file with ISO date target + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 2026-05-04 + entries: + - file: + name: 1755268130-test-feature.yaml + checksum: {ComputeSha1(changelog1)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir + // Note: Title is not set, should default to formatted date + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + // Check that output directory uses raw date slug + var indexFile = FileSystem.Path.Join(outputDir, "2026-05-04", "index.md"); + FileSystem.File.Exists(indexFile).Should().BeTrue(); + + // Check that heading uses formatted date but anchor uses raw date + var indexContent = await FileSystem.File.ReadAllTextAsync(indexFile, TestContext.Current.CancellationToken); + indexContent.Should().Contain("## May 4, 2026 [elastic-release-notes-2026-05-04]"); + } + + [Fact] + public async Task RenderChangelogs_WithYearMonthTarget_FormatsDateInHeading() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file with year-month target + // language=yaml + var changelog1 = + """ + title: Test feature + type: feature + products: + - product: elasticsearch + target: 2026-05 + prs: + - "100" + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "1755268130-test-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); + + // Create bundle file with year-month target + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 2026-05 + entries: + - file: + name: 1755268130-test-feature.yaml + checksum: {ComputeSha1(changelog1)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir + // Note: Title is not set, should default to formatted date + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + // Check that output directory uses raw date slug + var indexFile = FileSystem.Path.Join(outputDir, "2026-05", "index.md"); + FileSystem.File.Exists(indexFile).Should().BeTrue(); + + // Check that heading uses formatted date but anchor uses raw date + var indexContent = await FileSystem.File.ReadAllTextAsync(indexFile, TestContext.Current.CancellationToken); + indexContent.Should().Contain("## May 2026 [elastic-release-notes-2026-05]"); + } + + [Fact] + public async Task RenderChangelogs_WithExplicitDateTitle_DoesNotFormatTitle() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file with ISO date target + // language=yaml + var changelog1 = + """ + title: Test feature + type: feature + products: + - product: elasticsearch + target: 2026-05-04 + prs: + - "100" + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "1755268130-test-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); + + // Create bundle file with ISO date target + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 2026-05-04 + entries: + - file: + name: 1755268130-test-feature.yaml + checksum: {ComputeSha1(changelog1)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "2026-05-04" // Explicit title provided - should stay literal + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + // Check that output directory uses title slug + var indexFile = FileSystem.Path.Join(outputDir, "2026-05-04", "index.md"); + FileSystem.File.Exists(indexFile).Should().BeTrue(); + + // Check that heading uses literal title (no formatting applied) + var indexContent = await FileSystem.File.ReadAllTextAsync(indexFile, TestContext.Current.CancellationToken); + indexContent.Should().Contain("## 2026-05-04 [elastic-release-notes-2026-05-04]"); + } }