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..cd5b84e6c3 100644 --- a/docs/cli/changelog/cmd-render.md +++ b/docs/cli/changelog/cmd-render.md @@ -52,6 +52,33 @@ 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 + +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 Changelog entries can reference multiple pull requests and issues via the `prs` and `issues` array fields. All links are rendered inline: @@ -66,7 +93,7 @@ Changelog entries can reference multiple pull requests and issues via the `prs` # Render a single bundle docs-builder changelog render \ --input "./docs/changelog/bundles/9.3.0.yaml" \ - --output ./release-notes + --output ./release-notes \ # Render with explicit changelog dir and repo docs-builder changelog render \ @@ -94,4 +121,10 @@ 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 ``` 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..e6e71524e6 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs @@ -0,0 +1,480 @@ +// 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."); + + // 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(); + 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."); + } + + [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."); + } +}