diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 1e089f1443..9d6d52fb9e 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -4500,7 +4500,7 @@ "shortName": null, "type": "string", "required": false, - "summary": "Optional: Output file type. Valid values: \u0022markdown\u0022 or \u0022asciidoc\u0022. Defaults to \u0022markdown\u0022", + "summary": "Optional: Output file type. Valid values: \u0022markdown\u0022, \u0022asciidoc\u0022, or \u0022gfm\u0022. Defaults to \u0022markdown\u0022", "defaultValue": "markdown" }, { @@ -4539,6 +4539,15 @@ "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": "no-descriptions", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: Hide changelog record descriptions from output. When enabled, entry titles, PR/issue links, Impact and Action sections remain visible. Bundle-level descriptions are unaffected. 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 ffd314a2b0..51f4a37973 100644 --- a/docs/cli/changelog/cmd-render.md +++ b/docs/cli/changelog/cmd-render.md @@ -14,6 +14,9 @@ The `changelog render` command does **not** use `rules.publish` for filtering. F : `--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. +: `--no-descriptions` + Hide changelog entry descriptions from output. Entry titles, PR/issue links, and `Impact` / `Action` sections remain visible. Bundle-level descriptions are unaffected. Applies to all output formats (markdown, asciidoc, gfm). Defaults to false. + : `--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. @@ -52,6 +55,27 @@ AsciiDoc output ignores the `--dropdowns` flag and always uses a standardized fo - 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. +### GFM format + +When `--file-type gfm` is specified, the command generates a single GitHub Flavored Markdown file optimized for GitHub releases: + +- `changelog.md` - Contains all sections in a single file with clean headings +- Clean section headings without anchor links (for example, `### Features and enhancements`) +- Simplified structure focused on readability +- Suitable for copy/pasting into GitHub releases + +The GFM output includes the following sections in order when entries are present: + +- Highlights (only included when at least one entry has `highlight: true`) +- Features and enhancements +- Breaking changes +- Deprecations +- Bug fixes (includes security updates) +- Known issues +- Documentation +- Regressions +- Other changes + ### Multiple PR and issue links Changelog entries can reference multiple pull requests and issues via the `prs` and `issues` array fields. All links are rendered inline: @@ -94,4 +118,16 @@ docs-builder changelog render \ --input "./docs/changelog/bundles/9.3.0.yaml" \ --output ./release-notes \ --dropdowns + +# Render as GitHub Flavored Markdown +docs-builder changelog render \ + --input "./docs/changelog/bundles/9.3.0.yaml" \ + --output ./release-notes \ + --file-type gfm + +# Render without entry descriptions (titles, links, Impact/Action still shown) +docs-builder changelog render \ + --input "./docs/changelog/bundles/9.3.0.yaml" \ + --output ./release-notes \ + --no-descriptions ``` diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs index d9aa5fcb6c..85d8b8809b 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs @@ -63,9 +63,9 @@ private static void RenderEntryTitleAndLinks(StringBuilder sb, ChangelogEntry en /// /// Renders an entry's description with optional comment handling and list continuation /// - private static void RenderEntryDescription(StringBuilder sb, ChangelogEntry entry, bool shouldHide, bool needsContinuation = true) + private static void RenderEntryDescription(StringBuilder sb, ChangelogEntry entry, ChangelogRenderContext context, bool shouldHide, bool needsContinuation = true) { - if (string.IsNullOrWhiteSpace(entry.Description)) + if (context.HideDescriptions || string.IsNullOrWhiteSpace(entry.Description)) return; _ = sb.AppendLine(); @@ -113,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, needsContinuation: !string.IsNullOrWhiteSpace(entry.Description)); + RenderEntryDescription(sb, entry, context, shouldHide, needsContinuation: !string.IsNullOrWhiteSpace(entry.Description)); _ = sb.AppendLine(); } @@ -127,7 +127,7 @@ protected void RenderEntryWithImpactAction(StringBuilder sb, ChangelogEntry entr // Description needs continuation when it exists var hasDescription = !string.IsNullOrWhiteSpace(entry.Description); - RenderEntryDescription(sb, entry, shouldHide, needsContinuation: hasDescription); + RenderEntryDescription(sb, entry, context, shouldHide, needsContinuation: hasDescription); RenderImpactAndAction(sb, entry); _ = sb.AppendLine(); diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs index 19a949aa76..dac9524d55 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs @@ -21,6 +21,7 @@ public record ChangelogRenderContext public required IReadOnlyDictionary> EntriesByType { get; init; } public required bool Subsections { get; init; } public required bool Dropdowns { get; init; } + public required bool HideDescriptions { 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/ChangelogRenderer.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs index 2f901c29d4..5e609cd243 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs @@ -33,6 +33,10 @@ public async Task RenderAsync( await RenderMarkdownAsync(context, ctx); break; + case ChangelogFileType.Gfm: + await RenderGfmAsync(context, ctx); + break; + default: throw new ArgumentException($"Unknown changelog file type: {fileType}", nameof(fileType)); } @@ -51,4 +55,11 @@ private async Task RenderMarkdownAsync(ChangelogRenderContext context, Cancel ct await markdownRenderer.RenderAsync(context, ctx); logger.LogInformation("Rendered changelog markdown files to {OutputDir}", context.OutputDir); } + + private async Task RenderGfmAsync(ChangelogRenderContext context, Cancel ctx) + { + var gfmRenderer = new ChangelogGfmRenderer(fileSystem); + await gfmRenderer.RenderAsync(context, ctx); + logger.LogInformation("Rendered changelog GFM file to {OutputDir}", context.OutputDir); + } } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 068212a72a..0c8188b41b 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -32,6 +32,7 @@ public record RenderChangelogsArguments public string[]? HideFeatures { get; init; } public string? Config { get; init; } public ChangelogFileType FileType { get; init; } = ChangelogFileType.Markdown; + public bool HideDescriptions { get; init; } } @@ -59,11 +60,14 @@ public enum ChangelogFileType Markdown, [Display(Name = "asciidoc")] [JsonStringEnumMemberName("asciidoc")] - Asciidoc + Asciidoc, + [Display(Name = "gfm")] + [JsonStringEnumMemberName("gfm")] + Gfm } /// -/// Service for rendering changelog output (markdown or asciidoc) +/// Service for rendering changelog output (markdown, asciidoc, or gfm) /// public class ChangelogRenderingService( ILoggerFactory logFactory, @@ -341,6 +345,7 @@ private static ChangelogRenderContext BuildRenderContext( EntriesByType = entriesByType, Subsections = input.Subsections, Dropdowns = input.Dropdowns, + HideDescriptions = input.HideDescriptions, 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 4cee9acece..0434f2c902 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs @@ -68,7 +68,8 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct { // Dropdown rendering (current logic) _ = sb.AppendLine(InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}"); - _ = sb.AppendLine(entry.Description ?? "% Describe the functionality that changed"); + if (!context.HideDescriptions) + _ = sb.AppendLine(entry.Description ?? "% Describe the functionality that changed"); _ = sb.AppendLine(); RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks)); @@ -92,7 +93,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); // Description with proper indentation - if (!string.IsNullOrWhiteSpace(entry.Description)) + if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description)) { _ = sb.AppendLine(ChangelogTextUtilities.Indent(entry.Description)); _ = sb.AppendLine(); diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs new file mode 100644 index 0000000000..38a4afe3e5 --- /dev/null +++ b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs @@ -0,0 +1,293 @@ +// 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.Collections.Generic; +using System.IO.Abstractions; +using System.Text; +using Elastic.Documentation.ReleaseNotes; +using Nullean.ScopedFileSystem; +using static System.Globalization.CultureInfo; +using static Elastic.Documentation.ChangelogEntryType; + +namespace Elastic.Changelog.Rendering.Markdown; + +/// +/// Renderer for generating clean GitHub Flavored Markdown in a single changelog.md file +/// +public class ChangelogGfmRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +{ + /// + public override string OutputFileName => "changelog.md"; + + /// + public override async Task RenderAsync(ChangelogRenderContext context, Cancel ctx) + { + var entriesByType = context.EntriesByType; + var features = entriesByType.GetValueOrDefault(Feature, []); + var enhancements = entriesByType.GetValueOrDefault(Enhancement, []); + var security = entriesByType.GetValueOrDefault(Security, []); + var bugFixes = entriesByType.GetValueOrDefault(BugFix, []); + var docs = entriesByType.GetValueOrDefault(Docs, []); + var regressions = entriesByType.GetValueOrDefault(Regression, []); + var other = entriesByType.GetValueOrDefault(Other, []); + var breakingChanges = entriesByType.GetValueOrDefault(BreakingChange, []); + var deprecations = entriesByType.GetValueOrDefault(Deprecation, []); + var knownIssues = entriesByType.GetValueOrDefault(KnownIssue, []); + + // Check for highlights + var highlights = entriesByType.Values + .SelectMany(e => e) + .Where(e => e.Highlight == true) + .ToList(); + + var sb = new StringBuilder(); + + // Main heading - clean without anchors + _ = sb.AppendLine(InvariantCulture, $"## {context.Title}"); + + // Release date if present + if (context.BundleReleaseDate is { } releaseDate) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(InvariantCulture, $"_Released: {releaseDate.ToString("MMMM d, yyyy", InvariantCulture)}_"); + } + + // Add description if present + if (!string.IsNullOrEmpty(context.BundleDescription)) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(context.BundleDescription); + } + + _ = sb.AppendLine(); + + // Helper to check if all entries in a collection are hidden + bool AllEntriesHidden(IReadOnlyCollection entries) => + entries.Count > 0 && entries.All(entry => + ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + + // Render highlights first if any exist + if (highlights.Count > 0) + { + _ = sb.AppendLine("### Highlights"); + RenderEntriesByArea(sb, highlights, context); + _ = sb.AppendLine(); + } + + // Features and enhancements + if (features.Count > 0 || enhancements.Count > 0) + { + var combined = features.Concat(enhancements).ToList(); + if (!AllEntriesHidden(combined)) + { + _ = sb.AppendLine("### Features and enhancements"); + RenderEntriesByArea(sb, combined, context); + _ = sb.AppendLine(); + } + } + + // Breaking changes + if (breakingChanges.Count > 0 && !AllEntriesHidden(breakingChanges)) + { + _ = sb.AppendLine("### Breaking changes"); + RenderEntriesByArea(sb, breakingChanges, context); + _ = sb.AppendLine(); + } + + // Deprecations + if (deprecations.Count > 0 && !AllEntriesHidden(deprecations)) + { + _ = sb.AppendLine("### Deprecations"); + RenderEntriesByArea(sb, deprecations, context); + _ = sb.AppendLine(); + } + + // Bug fixes and security updates + if (security.Count > 0 || bugFixes.Count > 0) + { + var combined = security.Concat(bugFixes).ToList(); + if (!AllEntriesHidden(combined)) + { + _ = sb.AppendLine("### Bug fixes"); + RenderEntriesByArea(sb, combined, context); + _ = sb.AppendLine(); + } + } + + // Known issues + if (knownIssues.Count > 0 && !AllEntriesHidden(knownIssues)) + { + _ = sb.AppendLine("### Known issues"); + RenderEntriesByArea(sb, knownIssues, context); + _ = sb.AppendLine(); + } + + // Documentation + if (docs.Count > 0 && !AllEntriesHidden(docs)) + { + _ = sb.AppendLine("### Documentation"); + RenderEntriesByArea(sb, docs, context); + _ = sb.AppendLine(); + } + + // Regressions + if (regressions.Count > 0 && !AllEntriesHidden(regressions)) + { + _ = sb.AppendLine("### Regressions"); + RenderEntriesByArea(sb, regressions, context); + _ = sb.AppendLine(); + } + + // Other changes + if (other.Count > 0 && !AllEntriesHidden(other)) + { + _ = sb.AppendLine("### Other changes"); + RenderEntriesByArea(sb, other, context); + _ = sb.AppendLine(); + } + + // Check if we have any visible content + var hasAnyVisibleContent = highlights.Count > 0 || + (!AllEntriesHidden(features) && features.Count > 0) || + (!AllEntriesHidden(enhancements) && enhancements.Count > 0) || + (!AllEntriesHidden(breakingChanges) && breakingChanges.Count > 0) || + (!AllEntriesHidden(deprecations) && deprecations.Count > 0) || + (!AllEntriesHidden(security) && security.Count > 0) || + (!AllEntriesHidden(bugFixes) && bugFixes.Count > 0) || + (!AllEntriesHidden(knownIssues) && knownIssues.Count > 0) || + (!AllEntriesHidden(docs) && docs.Count > 0) || + (!AllEntriesHidden(regressions) && regressions.Count > 0) || + (!AllEntriesHidden(other) && other.Count > 0); + + if (!hasAnyVisibleContent) + { + _ = sb.AppendLine("_There are no new features, enhancements, or fixes associated with this release._"); + _ = sb.AppendLine(); + } + + await WriteOutputFileAsync(context.OutputDir, context.TitleSlug, sb.ToString(), ctx); + } + + private static void RenderEntriesByArea( + StringBuilder sb, + IReadOnlyCollection entries, + ChangelogRenderContext context) + { + var groupedByArea = context.Subsections + ? entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList() + : entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).ToList(); + + foreach (var areaGroup in groupedByArea) + { + // Check if all entries in this area group are hidden + var allEntriesHidden = areaGroup.All(entry => + ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + + if (context.Subsections && !string.IsNullOrWhiteSpace(areaGroup.Key)) + { + var header = ChangelogTextUtilities.FormatAreaHeader(areaGroup.Key); + if (allEntriesHidden) + _ = sb.Append("% "); + _ = sb.AppendLine(InvariantCulture, $"**{header}**"); + _ = sb.AppendLine(); + } + + foreach (var entry in areaGroup) + { + var (entryRepo, entryOwner, entryHideLinks, shouldHide) = ChangelogRenderUtilities.GetEntryContext(entry, context); + + if (shouldHide) + _ = sb.Append("% "); + _ = sb.Append("* "); + _ = sb.Append(ChangelogTextUtilities.Beautify(entry.Title)); + + var hasCommentedLinks = false; + if (entryHideLinks) + { + foreach (var pr in entry.Prs ?? []) + { + var formatted = ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner); + if (string.IsNullOrEmpty(formatted)) + continue; + + _ = sb.AppendLine(); + if (shouldHide) + _ = sb.Append("% "); + _ = sb.Append(" "); + _ = sb.Append(formatted); + hasCommentedLinks = true; + } + + foreach (var issue in entry.Issues ?? []) + { + var formatted = ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner); + if (string.IsNullOrEmpty(formatted)) + continue; + + _ = sb.AppendLine(); + if (shouldHide) + _ = sb.Append("% "); + _ = sb.Append(" "); + _ = sb.Append(formatted); + hasCommentedLinks = true; + } + + if (hasCommentedLinks) + _ = sb.AppendLine(); + } + else + { + var linkParts = new List(); + foreach (var pr in entry.Prs ?? []) + { + var s = ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner); + if (!string.IsNullOrEmpty(s)) + linkParts.Add(s); + } + + foreach (var issue in entry.Issues ?? []) + { + var s = ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner); + if (!string.IsNullOrEmpty(s)) + linkParts.Add(s); + } + + if (linkParts.Count > 0) + { + _ = sb.Append(' '); + var first = true; + foreach (var s in linkParts) + { + if (!first) + _ = sb.Append(' '); + _ = sb.Append(s); + first = false; + } + } + } + + if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description)) + { + _ = sb.AppendLine(entryHideLinks && hasCommentedLinks ? " " : ""); + _ = sb.AppendLine(); + var indented = ChangelogTextUtilities.Indent(entry.Description); + if (shouldHide) + { + // Comment out each line of the description + var indentedLines = indented.Split('\n'); + foreach (var line in indentedLines) + { + _ = sb.Append("% "); + _ = sb.AppendLine(line); + } + } + else + _ = sb.AppendLine(indented); + } + else + _ = sb.AppendLine(); + } + } + } +} diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs index c5b80228b4..048612f32d 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs @@ -65,7 +65,8 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct { // Dropdown rendering (current logic) _ = sb.AppendLine(InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}"); - _ = sb.AppendLine(entry.Description ?? "% Describe the functionality that was deprecated"); + if (!context.HideDescriptions) + _ = sb.AppendLine(entry.Description ?? "% Describe the functionality that was deprecated"); _ = sb.AppendLine(); RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks)); @@ -89,7 +90,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); // Description with proper indentation - if (!string.IsNullOrWhiteSpace(entry.Description)) + if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description)) { _ = sb.AppendLine(ChangelogTextUtilities.Indent(entry.Description)); _ = sb.AppendLine(); diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs index 5d5cf5a2e7..22d39044b9 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs @@ -68,7 +68,8 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct { // Dropdown rendering (current logic) _ = sb.AppendLine(InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}"); - _ = sb.AppendLine(entry.Description ?? "% Describe the highlight"); + if (!context.HideDescriptions) + _ = sb.AppendLine(entry.Description ?? "% Describe the highlight"); _ = sb.AppendLine(); RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks)); _ = sb.AppendLine("::::"); @@ -81,7 +82,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); // Description with proper indentation - if (!string.IsNullOrWhiteSpace(entry.Description)) + if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description)) { _ = sb.AppendLine(ChangelogTextUtilities.Indent(entry.Description)); _ = sb.AppendLine(); diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index c310834d2b..73f92dee39 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs @@ -240,7 +240,7 @@ private static void RenderEntriesByArea( } } - if (!string.IsNullOrWhiteSpace(entry.Description)) + if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description)) { _ = sb.AppendLine(entryHideLinks && hasCommentedLinks ? " " : ""); _ = sb.AppendLine(); diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs index 3f6e3b22da..5b9dbe521a 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs @@ -65,7 +65,8 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct { // Dropdown rendering (current logic) _ = sb.AppendLine(InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}"); - _ = sb.AppendLine(entry.Description ?? "% Describe the known issue"); + if (!context.HideDescriptions) + _ = sb.AppendLine(entry.Description ?? "% Describe the known issue"); _ = sb.AppendLine(); RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks)); @@ -89,7 +90,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); // Description with proper indentation - if (!string.IsNullOrWhiteSpace(entry.Description)) + if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description)) { _ = sb.AppendLine(ChangelogTextUtilities.Indent(entry.Description)); _ = sb.AppendLine(); diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 3b023cf912..909c56823e 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -1145,11 +1145,12 @@ async static (s, collector, state, ctx) => await s.RemoveChangelogs(collector, s /// Render one or more changelog bundles to Markdown or AsciiDoc. /// Required: Bundle input(s) in format "bundle-file-path|changelog-file-path|repo|link-visibility" (use pipe as delimiter). To merge multiple bundles, separate them with commas. Only bundle-file-path is required. link-visibility can be "hide-links" or "keep-links" (default). Use "hide-links" for private repositories; when set, all PR and issue links for each affected entry are hidden (entries may have multiple links via the prs and issues arrays). Paths support tilde (~) expansion and relative paths. /// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' - /// Optional: Output file type. Valid values: "markdown" or "asciidoc". Defaults to "markdown" + /// Optional: Output file type. Valid values: "markdown", "asciidoc", or "gfm". Defaults to "markdown" /// 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: Hide changelog record descriptions from output. When enabled, entry titles, PR/issue links, Impact and Action sections remain visible. Bundle-level descriptions are unaffected. Defaults to false /// Optional: Title to use for section headers in output files. Defaults to version from first bundle /// [NoOptionsInjection] @@ -1161,6 +1162,7 @@ public async Task Render( string? output = null, bool subsections = false, bool dropdowns = false, + bool noDescriptions = false, string? title = null, CancellationToken ct = default ) @@ -1176,11 +1178,12 @@ public async Task Render( { "markdown" => ChangelogFileType.Markdown, "asciidoc" => ChangelogFileType.Asciidoc, + "gfm" => ChangelogFileType.Gfm, _ => null }; if (ft is null) { - collector.EmitError(string.Empty, $"Invalid file-type '{fileType}'. Valid values are 'markdown' or 'asciidoc'."); + collector.EmitError(string.Empty, $"Invalid file-type '{fileType}'. Valid values are 'markdown', 'asciidoc', or 'gfm'."); return 1; } @@ -1194,6 +1197,7 @@ public async Task Render( Title = title, Subsections = subsections, Dropdowns = dropdowns, + HideDescriptions = noDescriptions, HideFeatures = allFeatureIds.Count > 0 ? allFeatureIds.ToArray() : null, FileType = ft.Value, Config = config?.FullName diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/DescriptionVisibilityTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/DescriptionVisibilityTests.cs new file mode 100644 index 0000000000..2c3f6441fb --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/DescriptionVisibilityTests.cs @@ -0,0 +1,458 @@ +// 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 DescriptionVisibilityTests(ITestOutputHelper output) : RenderChangelogTestBase(output) +{ + [Fact] + public async Task RenderChangelogs_DefaultBehavior_IncludesDescriptionsInMarkdown() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file with description + // language=yaml + var changelog1 = + """ + title: Test feature with description + type: feature + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "100" + description: This is a detailed description of the test feature that should be visible by default. + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "test-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, 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: 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, Repo = "elasticsearch" }], + Output = outputDir, + FileType = ChangelogFileType.Markdown, + HideDescriptions = false // Default behavior + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var indexMarkdown = FileSystem.Path.Join(outputDir, "9.2.0", "index.md"); + FileSystem.File.Exists(indexMarkdown).Should().BeTrue(); + + var indexContent = await FileSystem.File.ReadAllTextAsync(indexMarkdown, TestContext.Current.CancellationToken); + indexContent.Should().Contain("Test feature with description"); + indexContent.Should().Contain("This is a detailed description of the test feature that should be visible by default."); + indexContent.Should().Contain("[#100](https://github.com/elastic/elasticsearch/pull/100)"); + } + + [Fact] + public async Task RenderChangelogs_NoDescriptionsFlag_HidesDescriptionsInMarkdown() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file with description + // language=yaml + var changelog1 = + """ + title: Test feature with hidden description + type: feature + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "200" + description: This description should be hidden when --no-descriptions flag is used. + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "test-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, 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: 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, Repo = "elasticsearch" }], + Output = outputDir, + FileType = ChangelogFileType.Markdown, + HideDescriptions = true // Hide descriptions + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var indexMarkdown = FileSystem.Path.Join(outputDir, "9.2.0", "index.md"); + FileSystem.File.Exists(indexMarkdown).Should().BeTrue(); + + var indexContent = await FileSystem.File.ReadAllTextAsync(indexMarkdown, TestContext.Current.CancellationToken); + + // Title and links should still be present + indexContent.Should().Contain("Test feature with hidden description"); + indexContent.Should().Contain("[#200](https://github.com/elastic/elasticsearch/pull/200)"); + + // Description should be hidden + indexContent.Should().NotContain("This description should be hidden when --no-descriptions flag is used."); + } + + [Fact] + public async Task RenderChangelogs_NoDescriptionsFlag_HidesDescriptionsInAsciidoc() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file with description + // language=yaml + var changelog1 = + """ + title: Test feature for asciidoc + type: feature + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "300" + description: This description should be hidden in asciidoc format when --no-descriptions is used. + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "test-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, 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: 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, Repo = "elasticsearch" }], + Output = outputDir, + FileType = ChangelogFileType.Asciidoc, + HideDescriptions = true // Hide descriptions + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var asciidocFiles = FileSystem.Directory.GetFiles(outputDir, "*.asciidoc", SearchOption.AllDirectories); + asciidocFiles.Should().HaveCount(1); + + var asciidocContent = await FileSystem.File.ReadAllTextAsync(asciidocFiles[0], TestContext.Current.CancellationToken); + + // Title and links should still be present + asciidocContent.Should().Contain("Test feature for asciidoc"); + asciidocContent.Should().Contain("{es-pull}300[#300]"); + + // Description should be hidden + asciidocContent.Should().NotContain("This description should be hidden in asciidoc format when --no-descriptions is used."); + } + + [Fact] + public async Task RenderChangelogs_NoDescriptionsFlag_PreservesImpactAndActionForBreakingChanges() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create breaking change with description, impact, and action + // language=yaml + var changelog1 = + """ + title: Breaking change test + type: breaking-change + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "400" + description: This description should be hidden but Impact and Action should remain. + impact: This is the impact section that should always be visible. + action: This is the action section that should always be visible. + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "breaking-change.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, 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(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, Repo = "elasticsearch" }], + Output = outputDir, + FileType = ChangelogFileType.Markdown, + HideDescriptions = true, + Dropdowns = false // Test flattened mode + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var breakingChangesMarkdown = FileSystem.Path.Join(outputDir, "9.2.0", "breaking-changes.md"); + FileSystem.File.Exists(breakingChangesMarkdown).Should().BeTrue(); + + var breakingChangesContent = await FileSystem.File.ReadAllTextAsync(breakingChangesMarkdown, TestContext.Current.CancellationToken); + + // Title and links should be present + breakingChangesContent.Should().Contain("Breaking change test"); + breakingChangesContent.Should().Contain("[#400](https://github.com/elastic/elasticsearch/pull/400)"); + + // Description should be hidden + breakingChangesContent.Should().NotContain("This description should be hidden but Impact and Action should remain."); + + // Impact and Action should still be visible + breakingChangesContent.Should().Contain("**Impact:** This is the impact section that should always be visible."); + breakingChangesContent.Should().Contain("**Action:** This is the action section that should always be visible."); + } + + [Fact] + public async Task RenderChangelogs_NoDescriptionsFlag_WorksWithDropdownsMode() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create breaking change with description + // language=yaml + var changelog1 = + """ + title: Breaking change for dropdown test + type: breaking-change + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "500" + description: This description should be hidden in dropdown mode. + impact: Impact visible in dropdown mode. + action: Action visible in dropdown mode. + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "breaking-change.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, 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(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, Repo = "elasticsearch" }], + Output = outputDir, + FileType = ChangelogFileType.Markdown, + HideDescriptions = true, + Dropdowns = true // Test dropdown mode + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var breakingChangesMarkdown = FileSystem.Path.Join(outputDir, "9.2.0", "breaking-changes.md"); + FileSystem.File.Exists(breakingChangesMarkdown).Should().BeTrue(); + + var breakingChangesContent = await FileSystem.File.ReadAllTextAsync(breakingChangesMarkdown, TestContext.Current.CancellationToken); + + // Should have dropdown structure + breakingChangesContent.Should().Contain("::::{dropdown} Breaking change for dropdown test"); + breakingChangesContent.Should().Contain("::::"); + + // Links should be present + breakingChangesContent.Should().Contain("[#500](https://github.com/elastic/elasticsearch/pull/500)"); + + // Description should be hidden (no placeholder text either) + breakingChangesContent.Should().NotContain("This description should be hidden in dropdown mode."); + breakingChangesContent.Should().NotContain("% Describe the functionality that changed"); + + // Impact and Action should still be visible + breakingChangesContent.Should().Contain("**Impact**
Impact visible in dropdown mode."); + breakingChangesContent.Should().Contain("**Action**
Action visible in dropdown mode."); + } + + [Fact] + public async Task RenderChangelogs_NoDescriptionsFlag_PreservesBundleDescription() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file with description + // language=yaml + var changelog1 = + """ + title: Test feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + description: This entry description should be hidden. + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "test-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); + + // Create bundle file with bundle-level description + 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 + description: | + This is the bundle-level description that should always be visible + regardless of the --no-descriptions flag. + entries: + - file: + name: 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, Repo = "elasticsearch" }], + Output = outputDir, + FileType = ChangelogFileType.Markdown, + HideDescriptions = true + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var indexMarkdown = FileSystem.Path.Join(outputDir, "9.2.0", "index.md"); + FileSystem.File.Exists(indexMarkdown).Should().BeTrue(); + + var indexContent = await FileSystem.File.ReadAllTextAsync(indexMarkdown, TestContext.Current.CancellationToken); + + // Bundle description should be visible + indexContent.Should().Contain("This is the bundle-level description that should always be visible"); + indexContent.Should().Contain("regardless of the --no-descriptions flag."); + + // Entry title should be present + indexContent.Should().Contain("Test feature"); + + // Entry description should be hidden + indexContent.Should().NotContain("This entry description should be hidden."); + } +} diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs new file mode 100644 index 0000000000..7d023cfc60 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs @@ -0,0 +1,483 @@ +// 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 AwesomeAssertions; +using Elastic.Changelog.Bundling; +using Elastic.Changelog.Rendering; +using Elastic.Documentation.Configuration; + +namespace Elastic.Changelog.Tests.Changelogs.Render; + +public class GfmRenderTests(ITestOutputHelper output) : RenderChangelogTestBase(output) +{ + [Fact] + public async Task RenderChangelogs_WithGfmFileType_CreatesSingleGfmFile() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file + // language=yaml + var changelog = + """ + title: Test feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "100" + description: This is a test feature + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "test-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, 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: test-feature.yaml + checksum: {ComputeSha1(changelog)} + """; + 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, Repo = "elasticsearch" }], + Output = outputDir, + Title = "9.2.0", + FileType = ChangelogFileType.Gfm + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + // Should create only changelog.md file, not multiple files like regular markdown + var outputFile = FileSystem.Path.Join(outputDir, "9.2.0", "changelog.md"); + FileSystem.File.Exists(outputFile).Should().BeTrue(); + + // Should NOT create separate files + var indexFile = FileSystem.Path.Join(outputDir, "9.2.0", "index.md"); + var breakingChangesFile = FileSystem.Path.Join(outputDir, "9.2.0", "breaking-changes.md"); + FileSystem.File.Exists(indexFile).Should().BeFalse(); + FileSystem.File.Exists(breakingChangesFile).Should().BeFalse(); + + var content = await FileSystem.File.ReadAllTextAsync(outputFile, TestContext.Current.CancellationToken); + content.Should().Contain("## 9.2.0"); + content.Should().Contain("### Features and enhancements"); + content.Should().Contain("Test feature"); + content.Should().Contain("[#100](https://github.com/elastic/elasticsearch/pull/100)"); + + // Should NOT contain anchor brackets in headings + content.Should().NotContain("## 9.2.0 ["); + content.Should().NotContain("### Features and enhancements ["); + } + + [Fact] + public async Task RenderChangelogs_WithGfmFileType_IncludesAllSectionTypes() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog files for different types + // language=yaml + var feature = + """ + title: New feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + """; + + // language=yaml + var breakingChange = + """ + title: Breaking change + type: breaking-change + products: + - product: elasticsearch + target: 9.2.0 + """; + + // language=yaml + var deprecation = + """ + title: Deprecated API + type: deprecation + products: + - product: elasticsearch + target: 9.2.0 + """; + + // language=yaml + var bugFix = + """ + title: Bug fix + type: bug-fix + products: + - product: elasticsearch + target: 9.2.0 + """; + + // language=yaml + var knownIssue = + """ + title: Known issue + type: known-issue + products: + - product: elasticsearch + target: 9.2.0 + """; + + var featureFile = FileSystem.Path.Join(changelogDir, "feature.yaml"); + var breakingFile = FileSystem.Path.Join(changelogDir, "breaking.yaml"); + var deprecationFile = FileSystem.Path.Join(changelogDir, "deprecation.yaml"); + var bugFixFile = FileSystem.Path.Join(changelogDir, "bugfix.yaml"); + var knownIssueFile = FileSystem.Path.Join(changelogDir, "known-issue.yaml"); + + await FileSystem.File.WriteAllTextAsync(featureFile, feature, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(breakingFile, breakingChange, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(deprecationFile, deprecation, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(bugFixFile, bugFix, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(knownIssueFile, 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: feature.yaml + checksum: {ComputeSha1(feature)} + - file: + name: breaking.yaml + checksum: {ComputeSha1(breakingChange)} + - file: + name: deprecation.yaml + checksum: {ComputeSha1(deprecation)} + - file: + name: bugfix.yaml + checksum: {ComputeSha1(bugFix)} + - 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, Repo = "elasticsearch" }], + Output = outputDir, + Title = "9.2.0", + FileType = ChangelogFileType.Gfm + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var outputChangelogPath = FileSystem.Path.Join(outputDir, "9.2.0", "changelog.md"); + var content = await FileSystem.File.ReadAllTextAsync(outputChangelogPath, TestContext.Current.CancellationToken); + + // Should include all section types in the proper order + content.Should().Contain("### Features and enhancements"); + content.Should().Contain("### Breaking changes"); + content.Should().Contain("### Deprecations"); + content.Should().Contain("### Bug fixes"); + content.Should().Contain("### Known issues"); + + // Should contain the entry titles + content.Should().Contain("New feature"); + content.Should().Contain("Breaking change"); + content.Should().Contain("Deprecated API"); + content.Should().Contain("Bug fix"); + content.Should().Contain("Known issue"); + + // Check section ordering (features should come before breaking changes) + var featuresIndex = content.IndexOf("### Features and enhancements", StringComparison.Ordinal); + var breakingIndex = content.IndexOf("### Breaking changes", StringComparison.Ordinal); + var deprecationIndex = content.IndexOf("### Deprecations", StringComparison.Ordinal); + var bugFixIndex = content.IndexOf("### Bug fixes", StringComparison.Ordinal); + var knownIssueIndex = content.IndexOf("### Known issues", StringComparison.Ordinal); + + featuresIndex.Should().BeLessThan(breakingIndex); + breakingIndex.Should().BeLessThan(deprecationIndex); + deprecationIndex.Should().BeLessThan(bugFixIndex); + bugFixIndex.Should().BeLessThan(knownIssueIndex); + } + + [Fact] + public async Task RenderChangelogs_WithGfmFileType_HandlesHighlights() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog with highlight + // language=yaml + var highlightedFeature = + """ + title: Important new feature + type: feature + highlight: true + products: + - product: elasticsearch + target: 9.2.0 + """; + + // language=yaml + var normalFeature = + """ + title: Regular feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + """; + + var highlightFile = FileSystem.Path.Join(changelogDir, "highlight.yaml"); + var normalFile = FileSystem.Path.Join(changelogDir, "normal.yaml"); + + await FileSystem.File.WriteAllTextAsync(highlightFile, highlightedFeature, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(normalFile, normalFeature, 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(highlightedFeature)} + - file: + name: normal.yaml + checksum: {ComputeSha1(normalFeature)} + """; + 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, Repo = "elasticsearch" }], + Output = outputDir, + Title = "9.2.0", + FileType = ChangelogFileType.Gfm + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var highlightsOutputFile = FileSystem.Path.Join(outputDir, "9.2.0", "changelog.md"); + var content = await FileSystem.File.ReadAllTextAsync(highlightsOutputFile, TestContext.Current.CancellationToken); + + // Should include highlights section first + content.Should().Contain("### Highlights"); + content.Should().Contain("### Features and enhancements"); + + // Highlights should come first + var highlightsIndex = content.IndexOf("### Highlights", StringComparison.Ordinal); + var featuresIndex = content.IndexOf("### Features and enhancements", StringComparison.Ordinal); + highlightsIndex.Should().BeLessThan(featuresIndex); + + // Both features should be present + content.Should().Contain("Important new feature"); + content.Should().Contain("Regular feature"); + } + + [Fact] + public async Task RenderChangelogs_WithGfmFileType_HandlesDescriptionsAndHideDescriptions() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog with description + // language=yaml + var changelog = + """ + title: Feature with description + type: feature + products: + - product: elasticsearch + target: 9.2.0 + description: | + This is a detailed description of the feature. + It spans multiple lines. + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, 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: feature.yaml + checksum: {ComputeSha1(changelog)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + // Test with descriptions shown (default) + var inputWithDescriptions = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }], + Output = outputDir, + Title = "9.2.0", + FileType = ChangelogFileType.Gfm, + HideDescriptions = false + }; + + // Act + var result = await Service.RenderChangelogs(Collector, inputWithDescriptions, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var outputChangelogFile = FileSystem.Path.Join(outputDir, "9.2.0", "changelog.md"); + var content = await FileSystem.File.ReadAllTextAsync(outputChangelogFile, TestContext.Current.CancellationToken); + + content.Should().Contain("Feature with description"); + content.Should().Contain("This is a detailed description of the feature."); + content.Should().Contain("It spans multiple lines."); + + // Test with descriptions hidden + var outputDir2 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + var inputWithoutDescriptions = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }], + Output = outputDir2, + Title = "9.2.0", + FileType = ChangelogFileType.Gfm, + HideDescriptions = true + }; + + var result2 = await Service.RenderChangelogs(Collector, inputWithoutDescriptions, TestContext.Current.CancellationToken); + + result2.Should().BeTrue(); + Collector.Errors.Should().Be(0); + var changelogFile2 = FileSystem.Path.Join(outputDir2, "9.2.0", "changelog.md"); + var content2 = await FileSystem.File.ReadAllTextAsync(changelogFile2, TestContext.Current.CancellationToken); + + content2.Should().Contain("Feature with description"); + content2.Should().NotContain("This is a detailed description of the feature."); + content2.Should().NotContain("It spans multiple lines."); + } + + [Fact] + public async Task RenderChangelogs_WithGfmFileType_HandlesBundleDescriptionAndReleaseDate() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create simple changelog + // language=yaml + var changelog = + """ + title: Simple feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); + + // Create bundle file with description and release date + 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 + description: "This is a major release with many improvements." + release-date: "2024-03-15" + entries: + - file: + name: feature.yaml + checksum: {ComputeSha1(changelog)} + """; + 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, Repo = "elasticsearch" }], + Output = outputDir, + Title = "9.2.0", + FileType = ChangelogFileType.Gfm + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var finalChangelogFile = FileSystem.Path.Join(outputDir, "9.2.0", "changelog.md"); + var content = await FileSystem.File.ReadAllTextAsync(finalChangelogFile, TestContext.Current.CancellationToken); + + // Should include the bundle description and release date + content.Should().Contain("## 9.2.0"); + content.Should().Contain("_Released: March 15, 2024_"); + content.Should().Contain("This is a major release with many improvements."); + } +}