diff --git a/docs/cli/changelog/render.md b/docs/cli/changelog/render.md index 11202b07f5..2eabe5c726 100644 --- a/docs/cli/changelog/render.md +++ b/docs/cli/changelog/render.md @@ -41,10 +41,11 @@ The `render` command automatically discovers and merges `.amend-*.yaml` files wi ::: `--file-type ` -: Optional: Output file type. Valid values: `"markdown"` or `"asciidoc"`. +: Optional: Output file type. Valid values: `"markdown"`, `"asciidoc"`, or `"gfm"`. : Defaults to `"markdown"`. : When `"markdown"` is specified, the command generates multiple markdown files (index.md, breaking-changes.md, deprecations.md, known-issues.md). : When `"asciidoc"` is specified, the command generates a single asciidoc file with all sections. +: When `"gfm"` is specified, the command generates a single changelog.md file optimized for GitHub releases with clean headings and no anchor links. `--output ` : Optional: The output directory for rendered files. @@ -105,6 +106,27 @@ When `--file-type asciidoc` is specified, the command generates a single asciido The asciidoc output uses attribute references for links (for example, `{repo-pull}NUMBER[#NUMBER]`). +### 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 (e.g., `### 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 @@ -179,3 +201,12 @@ docs-builder changelog render \ --subsections \ --output ./release-notes ``` + +### Render as GitHub Flavored Markdown for releases + +```sh +docs-builder changelog render \ + --input "./bundles/9.3.0.yaml|./changelog|elasticsearch" \ + --file-type gfm \ + --output ./github-release +``` 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 b6a64e8c4c..0c8188b41b 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -60,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, 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/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 7fe7efabaa..6abc93a1b8 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -1148,7 +1148,7 @@ async static (s, collector, state, ctx) => await s.RemoveChangelogs(collector, s /// /// 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 @@ -1180,11 +1180,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; } 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."); + } +}