Skip to content

Commit e4b6e4e

Browse files
committed
Add gfm output format for changelog render (#3272)
1 parent a667907 commit e4b6e4e

6 files changed

Lines changed: 824 additions & 5 deletions

File tree

docs/cli/changelog/render.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,11 @@ The `render` command automatically discovers and merges `.amend-*.yaml` files wi
4141
:::
4242

4343
`--file-type <string>`
44-
: Optional: Output file type. Valid values: `"markdown"` or `"asciidoc"`.
44+
: Optional: Output file type. Valid values: `"markdown"`, `"asciidoc"`, or `"gfm"`.
4545
: Defaults to `"markdown"`.
4646
: When `"markdown"` is specified, the command generates multiple markdown files (index.md, breaking-changes.md, deprecations.md, known-issues.md).
4747
: When `"asciidoc"` is specified, the command generates a single asciidoc file with all sections.
48+
: When `"gfm"` is specified, the command generates a single changelog.md file optimized for GitHub releases with clean headings and no anchor links.
4849

4950
`--output <string?>`
5051
: Optional: The output directory for rendered files.
@@ -105,6 +106,27 @@ When `--file-type asciidoc` is specified, the command generates a single asciido
105106

106107
The asciidoc output uses attribute references for links (for example, `{repo-pull}NUMBER[#NUMBER]`).
107108

109+
### GFM format
110+
111+
When `--file-type gfm` is specified, the command generates a single GitHub Flavored Markdown file optimized for GitHub releases:
112+
113+
- `changelog.md` - Contains all sections in a single file with clean headings
114+
- Clean section headings without anchor links (e.g., `### Features and enhancements`)
115+
- Simplified structure focused on readability
116+
- Suitable for copy/pasting into GitHub releases
117+
118+
The GFM output includes the following sections in order when entries are present:
119+
120+
- Highlights (only included when at least one entry has `highlight: true`)
121+
- Features and enhancements
122+
- Breaking changes
123+
- Deprecations
124+
- Bug fixes (includes security updates)
125+
- Known issues
126+
- Documentation
127+
- Regressions
128+
- Other changes
129+
108130
AsciiDoc output ignores the `--dropdowns` flag and always uses a standardized format with the following characteristics:
109131

110132
- 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 \
179201
--subsections \
180202
--output ./release-notes
181203
```
204+
205+
### Render as GitHub Flavored Markdown for releases
206+
207+
```sh
208+
docs-builder changelog render \
209+
--input "./bundles/9.3.0.yaml|./changelog|elasticsearch" \
210+
--file-type gfm \
211+
--output ./github-release
212+
```

src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ public async Task RenderAsync(
3333
await RenderMarkdownAsync(context, ctx);
3434
break;
3535

36+
case ChangelogFileType.Gfm:
37+
await RenderGfmAsync(context, ctx);
38+
break;
39+
3640
default:
3741
throw new ArgumentException($"Unknown changelog file type: {fileType}", nameof(fileType));
3842
}
@@ -51,4 +55,11 @@ private async Task RenderMarkdownAsync(ChangelogRenderContext context, Cancel ct
5155
await markdownRenderer.RenderAsync(context, ctx);
5256
logger.LogInformation("Rendered changelog markdown files to {OutputDir}", context.OutputDir);
5357
}
58+
59+
private async Task RenderGfmAsync(ChangelogRenderContext context, Cancel ctx)
60+
{
61+
var gfmRenderer = new ChangelogGfmRenderer(fileSystem);
62+
await gfmRenderer.RenderAsync(context, ctx);
63+
logger.LogInformation("Rendered changelog GFM file to {OutputDir}", context.OutputDir);
64+
}
5465
}

src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,14 @@ public enum ChangelogFileType
6060
Markdown,
6161
[Display(Name = "asciidoc")]
6262
[JsonStringEnumMemberName("asciidoc")]
63-
Asciidoc
63+
Asciidoc,
64+
[Display(Name = "gfm")]
65+
[JsonStringEnumMemberName("gfm")]
66+
Gfm
6467
}
6568

6669
/// <summary>
67-
/// Service for rendering changelog output (markdown or asciidoc)
70+
/// Service for rendering changelog output (markdown, asciidoc, or gfm)
6871
/// </summary>
6972
public class ChangelogRenderingService(
7073
ILoggerFactory logFactory,
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Collections.Generic;
6+
using System.IO.Abstractions;
7+
using System.Text;
8+
using Elastic.Documentation.ReleaseNotes;
9+
using Nullean.ScopedFileSystem;
10+
using static System.Globalization.CultureInfo;
11+
using static Elastic.Documentation.ChangelogEntryType;
12+
13+
namespace Elastic.Changelog.Rendering.Markdown;
14+
15+
/// <summary>
16+
/// Renderer for generating clean GitHub Flavored Markdown in a single changelog.md file
17+
/// </summary>
18+
public class ChangelogGfmRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem)
19+
{
20+
/// <inheritdoc />
21+
public override string OutputFileName => "changelog.md";
22+
23+
/// <inheritdoc />
24+
public override async Task RenderAsync(ChangelogRenderContext context, Cancel ctx)
25+
{
26+
var entriesByType = context.EntriesByType;
27+
var features = entriesByType.GetValueOrDefault(Feature, []);
28+
var enhancements = entriesByType.GetValueOrDefault(Enhancement, []);
29+
var security = entriesByType.GetValueOrDefault(Security, []);
30+
var bugFixes = entriesByType.GetValueOrDefault(BugFix, []);
31+
var docs = entriesByType.GetValueOrDefault(Docs, []);
32+
var regressions = entriesByType.GetValueOrDefault(Regression, []);
33+
var other = entriesByType.GetValueOrDefault(Other, []);
34+
var breakingChanges = entriesByType.GetValueOrDefault(BreakingChange, []);
35+
var deprecations = entriesByType.GetValueOrDefault(Deprecation, []);
36+
var knownIssues = entriesByType.GetValueOrDefault(KnownIssue, []);
37+
38+
// Check for highlights
39+
var highlights = entriesByType.Values
40+
.SelectMany(e => e)
41+
.Where(e => e.Highlight == true)
42+
.ToList();
43+
44+
var sb = new StringBuilder();
45+
46+
// Main heading - clean without anchors
47+
_ = sb.AppendLine(InvariantCulture, $"## {context.Title}");
48+
49+
// Release date if present
50+
if (context.BundleReleaseDate is { } releaseDate)
51+
{
52+
_ = sb.AppendLine();
53+
_ = sb.AppendLine(InvariantCulture, $"_Released: {releaseDate.ToString("MMMM d, yyyy", InvariantCulture)}_");
54+
}
55+
56+
// Add description if present
57+
if (!string.IsNullOrEmpty(context.BundleDescription))
58+
{
59+
_ = sb.AppendLine();
60+
_ = sb.AppendLine(context.BundleDescription);
61+
}
62+
63+
_ = sb.AppendLine();
64+
65+
// Helper to check if all entries in a collection are hidden
66+
bool AllEntriesHidden(IReadOnlyCollection<ChangelogEntry> entries) =>
67+
entries.Count > 0 && entries.All(entry =>
68+
ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context));
69+
70+
// Render highlights first if any exist
71+
if (highlights.Count > 0)
72+
{
73+
_ = sb.AppendLine("### Highlights");
74+
RenderEntriesByArea(sb, highlights, context);
75+
_ = sb.AppendLine();
76+
}
77+
78+
// Features and enhancements
79+
if (features.Count > 0 || enhancements.Count > 0)
80+
{
81+
var combined = features.Concat(enhancements).ToList();
82+
if (!AllEntriesHidden(combined))
83+
{
84+
_ = sb.AppendLine("### Features and enhancements");
85+
RenderEntriesByArea(sb, combined, context);
86+
_ = sb.AppendLine();
87+
}
88+
}
89+
90+
// Breaking changes
91+
if (breakingChanges.Count > 0 && !AllEntriesHidden(breakingChanges))
92+
{
93+
_ = sb.AppendLine("### Breaking changes");
94+
RenderEntriesByArea(sb, breakingChanges, context);
95+
_ = sb.AppendLine();
96+
}
97+
98+
// Deprecations
99+
if (deprecations.Count > 0 && !AllEntriesHidden(deprecations))
100+
{
101+
_ = sb.AppendLine("### Deprecations");
102+
RenderEntriesByArea(sb, deprecations, context);
103+
_ = sb.AppendLine();
104+
}
105+
106+
// Bug fixes and security updates
107+
if (security.Count > 0 || bugFixes.Count > 0)
108+
{
109+
var combined = security.Concat(bugFixes).ToList();
110+
if (!AllEntriesHidden(combined))
111+
{
112+
_ = sb.AppendLine("### Bug fixes");
113+
RenderEntriesByArea(sb, combined, context);
114+
_ = sb.AppendLine();
115+
}
116+
}
117+
118+
// Known issues
119+
if (knownIssues.Count > 0 && !AllEntriesHidden(knownIssues))
120+
{
121+
_ = sb.AppendLine("### Known issues");
122+
RenderEntriesByArea(sb, knownIssues, context);
123+
_ = sb.AppendLine();
124+
}
125+
126+
// Documentation
127+
if (docs.Count > 0 && !AllEntriesHidden(docs))
128+
{
129+
_ = sb.AppendLine("### Documentation");
130+
RenderEntriesByArea(sb, docs, context);
131+
_ = sb.AppendLine();
132+
}
133+
134+
// Regressions
135+
if (regressions.Count > 0 && !AllEntriesHidden(regressions))
136+
{
137+
_ = sb.AppendLine("### Regressions");
138+
RenderEntriesByArea(sb, regressions, context);
139+
_ = sb.AppendLine();
140+
}
141+
142+
// Other changes
143+
if (other.Count > 0 && !AllEntriesHidden(other))
144+
{
145+
_ = sb.AppendLine("### Other changes");
146+
RenderEntriesByArea(sb, other, context);
147+
_ = sb.AppendLine();
148+
}
149+
150+
// Check if we have any visible content
151+
var hasAnyVisibleContent = highlights.Count > 0 ||
152+
(!AllEntriesHidden(features) && features.Count > 0) ||
153+
(!AllEntriesHidden(enhancements) && enhancements.Count > 0) ||
154+
(!AllEntriesHidden(breakingChanges) && breakingChanges.Count > 0) ||
155+
(!AllEntriesHidden(deprecations) && deprecations.Count > 0) ||
156+
(!AllEntriesHidden(security) && security.Count > 0) ||
157+
(!AllEntriesHidden(bugFixes) && bugFixes.Count > 0) ||
158+
(!AllEntriesHidden(knownIssues) && knownIssues.Count > 0) ||
159+
(!AllEntriesHidden(docs) && docs.Count > 0) ||
160+
(!AllEntriesHidden(regressions) && regressions.Count > 0) ||
161+
(!AllEntriesHidden(other) && other.Count > 0);
162+
163+
if (!hasAnyVisibleContent)
164+
{
165+
_ = sb.AppendLine("_There are no new features, enhancements, or fixes associated with this release._");
166+
_ = sb.AppendLine();
167+
}
168+
169+
await WriteOutputFileAsync(context.OutputDir, context.TitleSlug, sb.ToString(), ctx);
170+
}
171+
172+
private static void RenderEntriesByArea(
173+
StringBuilder sb,
174+
IReadOnlyCollection<ChangelogEntry> entries,
175+
ChangelogRenderContext context)
176+
{
177+
var groupedByArea = context.Subsections
178+
? entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList()
179+
: entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).ToList();
180+
181+
foreach (var areaGroup in groupedByArea)
182+
{
183+
// Check if all entries in this area group are hidden
184+
var allEntriesHidden = areaGroup.All(entry =>
185+
ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context));
186+
187+
if (context.Subsections && !string.IsNullOrWhiteSpace(areaGroup.Key))
188+
{
189+
var header = ChangelogTextUtilities.FormatAreaHeader(areaGroup.Key);
190+
if (allEntriesHidden)
191+
_ = sb.Append("% ");
192+
_ = sb.AppendLine(InvariantCulture, $"**{header}**");
193+
_ = sb.AppendLine();
194+
}
195+
196+
foreach (var entry in areaGroup)
197+
{
198+
var (entryRepo, entryOwner, entryHideLinks, shouldHide) = ChangelogRenderUtilities.GetEntryContext(entry, context);
199+
200+
if (shouldHide)
201+
_ = sb.Append("% ");
202+
_ = sb.Append("* ");
203+
_ = sb.Append(ChangelogTextUtilities.Beautify(entry.Title));
204+
205+
var hasCommentedLinks = false;
206+
if (entryHideLinks)
207+
{
208+
foreach (var pr in entry.Prs ?? [])
209+
{
210+
var formatted = ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner);
211+
if (string.IsNullOrEmpty(formatted))
212+
continue;
213+
214+
_ = sb.AppendLine();
215+
if (shouldHide)
216+
_ = sb.Append("% ");
217+
_ = sb.Append(" ");
218+
_ = sb.Append(formatted);
219+
hasCommentedLinks = true;
220+
}
221+
222+
foreach (var issue in entry.Issues ?? [])
223+
{
224+
var formatted = ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner);
225+
if (string.IsNullOrEmpty(formatted))
226+
continue;
227+
228+
_ = sb.AppendLine();
229+
if (shouldHide)
230+
_ = sb.Append("% ");
231+
_ = sb.Append(" ");
232+
_ = sb.Append(formatted);
233+
hasCommentedLinks = true;
234+
}
235+
236+
if (hasCommentedLinks)
237+
_ = sb.AppendLine();
238+
}
239+
else
240+
{
241+
var linkParts = new List<string>();
242+
foreach (var pr in entry.Prs ?? [])
243+
{
244+
var s = ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner);
245+
if (!string.IsNullOrEmpty(s))
246+
linkParts.Add(s);
247+
}
248+
249+
foreach (var issue in entry.Issues ?? [])
250+
{
251+
var s = ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner);
252+
if (!string.IsNullOrEmpty(s))
253+
linkParts.Add(s);
254+
}
255+
256+
if (linkParts.Count > 0)
257+
{
258+
_ = sb.Append(' ');
259+
var first = true;
260+
foreach (var s in linkParts)
261+
{
262+
if (!first)
263+
_ = sb.Append(' ');
264+
_ = sb.Append(s);
265+
first = false;
266+
}
267+
}
268+
}
269+
270+
if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description))
271+
{
272+
_ = sb.AppendLine(entryHideLinks && hasCommentedLinks ? " " : "");
273+
_ = sb.AppendLine();
274+
var indented = ChangelogTextUtilities.Indent(entry.Description);
275+
if (shouldHide)
276+
{
277+
// Comment out each line of the description
278+
var indentedLines = indented.Split('\n');
279+
foreach (var line in indentedLines)
280+
{
281+
_ = sb.Append("% ");
282+
_ = sb.AppendLine(line);
283+
}
284+
}
285+
else
286+
_ = sb.AppendLine(indented);
287+
}
288+
else
289+
_ = sb.AppendLine();
290+
}
291+
}
292+
}
293+
}

0 commit comments

Comments
 (0)