Skip to content

Commit 21884fa

Browse files
authored
Add --extract-release-notes to changelog add (#2483)
1 parent 028923c commit 21884fa

File tree

10 files changed

+854
-9
lines changed

10 files changed

+854
-9
lines changed

docs/cli/release/changelog-add.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,19 @@ docs-builder changelog add [options...] [-h|--help]
6565
: For example, if a PR title is `"[Attack discovery] Improves Attack discovery hallucination detection"`, the changelog title will be `"Improves Attack discovery hallucination detection"`.
6666
: This option applies only when the title is derived from the PR (when `--title` is not explicitly provided).
6767

68+
`--extract-release-notes`
69+
: Optional: When used with `--prs`, extract release notes from PR descriptions and use them in the changelog.
70+
: The extractor looks for content in various formats in the PR description:
71+
: - `Release Notes: ...`
72+
: - `Release-Notes: ...`
73+
: - `release notes: ...`
74+
: - `Release Note: ...`
75+
: - `Release Notes - ...`
76+
: - `## Release Note` (as a markdown header)
77+
: Short release notes (≤120 characters, single line) are used as the changelog title (only if `--title` is not explicitly provided).
78+
: Long release notes (>120 characters or multi-line) are used as the changelog description (only if `--description` is not explicitly provided).
79+
: If no release note is found, no changes are made to the title or description.
80+
6881
`--subtype <string?>`
6982
: Optional: Subtype for breaking changes (for example, `api`, `behavioral`, or `configuration`).
7083
: The valid subtypes are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs).

docs/contribute/changelog.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ Options:
141141
--config <string?> Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' [Default: null]
142142
--use-pr-number Optional: Use the PR number as the filename instead of generating it from a unique ID and title
143143
--strip-title-prefix Optional: When used with --prs, remove square brackets and text within them from the beginning of PR titles
144+
--extract-release-notes Optional: When used with --prs, extract release notes from PR descriptions. Short release notes (≤120 characters, single line) are used as the title, long release notes (>120 characters or multi-line) are used as the description. Looks for content in formats like "Release Notes: ...", "Release-Notes: ...", "## Release Note", etc.
144145
```
145146

146147
### Authorization
@@ -255,6 +256,29 @@ The `--strip-title-prefix` option in this example means that if the PR title has
255256
The `--strip-title-prefix` option only applies when the title is derived from the PR (when `--title` is not explicitly provided). If you specify `--title` explicitly, that title is used as-is without any prefix stripping.
256257
:::
257258

259+
#### Extract release notes from PR descriptions [example-extract-release-notes]
260+
261+
When you use the `--prs` option, you can also add the `--extract-release-notes` option to automatically extract text from the PR descriptions and use them in your changelog.
262+
263+
In particular, it looks for content in these formats in the PR description:
264+
265+
- `Release Notes: This is the extracted sentence.`
266+
- `Release-Notes: This is the extracted sentence.`
267+
- `release notes: This is the extracted sentence.`
268+
- `Release Note: This is the extracted sentence.`
269+
- `Release Notes - This is the extracted sentence.`
270+
- `## Release Note` (as a markdown header)
271+
272+
The extracted content is handled differently based on its length:
273+
274+
- **Short release notes (≤120 characters, single line)**: Used as the changelog title (only if `--title` is not explicitly provided)
275+
- **Long release notes (>120 characters or multi-line)**: Used as the changelog description (only if `--description` is not explicitly provided)
276+
- **No release note found**: No changes are made to the title or description
277+
278+
:::{note}
279+
If you explicitly provide `--title` or `--description`, those values take precedence over extracted release notes.
280+
:::
281+
258282
#### Block changelog creation with PR labels [example-block-label]
259283

260284
You can configure product-specific label blockers to prevent changelog creation for certain PRs based on their labels.

src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -720,16 +720,25 @@ public class LlmDefinitionItemRenderer : MarkdownObjectRenderer<LlmMarkdownRende
720720
{
721721
protected override void Write(LlmMarkdownRenderer renderer, DefinitionItem obj)
722722
{
723-
var first = obj.Cast<LeafBlock>().First();
724723
renderer.EnsureBlockSpacing();
724+
725+
// Render the term (first element) if it's a LeafBlock
726+
var first = obj.FirstOrDefault();
727+
var hasTermBlock = first is LeafBlock;
728+
725729
renderer.Write("<definition");
726-
renderer.Write(" term=\"");
727-
renderer.Write(GetPlainTextFromLeafBlock(renderer, first));
728-
renderer.WriteLine("\">");
729-
for (var index = 0; index < obj.Count; index++)
730+
if (hasTermBlock)
731+
{
732+
renderer.Write(" term=\"");
733+
renderer.Write(GetPlainTextFromLeafBlock(renderer, (LeafBlock)first!));
734+
renderer.Write("\"");
735+
}
736+
renderer.WriteLine(">");
737+
738+
// Render the definitions (remaining elements, or all if no term block)
739+
var startIndex = hasTermBlock ? 1 : 0;
740+
for (var index = startIndex; index < obj.Count; index++)
730741
{
731-
if (index == 0)
732-
continue;
733742
var block = obj[index];
734743
LlmRenderingHelpers.RenderBlockWithIndentation(renderer, block);
735744
}

src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,6 @@ public class ChangelogInput
2727
public string? Config { get; set; }
2828
public bool UsePrNumber { get; set; }
2929
public bool StripTitlePrefix { get; set; }
30+
public bool ExtractReleaseNotes { get; set; }
3031
}
3132

src/services/Elastic.Documentation.Services/Changelog/GitHubPrService.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ static GitHubPrService()
7171
return new GitHubPrInfo
7272
{
7373
Title = prData.Title,
74+
Body = prData.Body ?? string.Empty,
7475
Labels = prData.Labels?.Select(l => l.Name).ToArray() ?? []
7576
};
7677
}
@@ -140,6 +141,7 @@ private static (string? owner, string? repo, int? prNumber) ParsePrUrl(string pr
140141
private sealed class GitHubPrResponse
141142
{
142143
public string Title { get; set; } = string.Empty;
144+
public string Body { get; set; } = string.Empty;
143145
public List<GitHubLabel>? Labels { get; set; }
144146
}
145147

@@ -161,6 +163,7 @@ private sealed partial class GitHubPrJsonContext : JsonSerializerContext;
161163
public class GitHubPrInfo
162164
{
163165
public string Title { get; set; } = string.Empty;
166+
public string Body { get; set; } = string.Empty;
164167
public string[] Labels { get; set; } = [];
165168
}
166169

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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.Text.RegularExpressions;
6+
7+
namespace Elastic.Documentation.Services.Changelog;
8+
9+
/// <summary>
10+
/// Utility class for extracting release notes from PR descriptions
11+
/// </summary>
12+
public static partial class ReleaseNotesExtractor
13+
{
14+
[GeneratedRegex(@"<!--[\s\S]*?-->", RegexOptions.None)]
15+
private static partial Regex HtmlCommentRegex();
16+
17+
[GeneratedRegex(@"(\r?\n){3,}", RegexOptions.None)]
18+
private static partial Regex MultipleNewlinesRegex();
19+
20+
[GeneratedRegex(@"(?:\n|^)\s*#*\s*release[\s-]?notes?[:\s-]*(.*?)(?:(\r?\n|\r){2}|$|((\r?\n|\r)\s*#+))", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
21+
private static partial Regex ReleaseNoteRegex();
22+
23+
private const int MaxReleaseNoteTitleLength = 120;
24+
25+
/// <summary>
26+
/// Strips HTML comments from markdown text.
27+
/// This handles both single-line and multi-line comments.
28+
/// Also collapses excessive blank lines that may result from comment removal,
29+
/// to prevent creating artificial section breaks.
30+
/// </summary>
31+
private static string StripHtmlComments(string markdown)
32+
{
33+
if (string.IsNullOrWhiteSpace(markdown))
34+
{
35+
return markdown;
36+
}
37+
38+
// Remove HTML comments
39+
var withoutComments = HtmlCommentRegex().Replace(markdown, string.Empty);
40+
41+
// Collapse 3+ consecutive newlines into 2 (preserving paragraph breaks but not creating extra ones)
42+
var normalized = MultipleNewlinesRegex().Replace(withoutComments, "\n\n");
43+
44+
return normalized;
45+
}
46+
47+
/// <summary>
48+
/// Finds and retrieves the actual "release note" details from a PR description (in markdown format).
49+
/// It will look for:
50+
/// - paragraphs beginning with "release note" (or slight variations of that) and the sentence till the end of line.
51+
/// - markdown headers like "## Release Note"
52+
///
53+
/// HTML comments are stripped before extraction to avoid picking up template instructions.
54+
/// </summary>
55+
/// <param name="markdown">The PR description body</param>
56+
/// <returns>The extracted release note content, or null if not found</returns>
57+
public static string? FindReleaseNote(string? markdown)
58+
{
59+
if (string.IsNullOrWhiteSpace(markdown))
60+
{
61+
return null;
62+
}
63+
64+
// Strip HTML comments first to avoid extracting template instructions
65+
var cleanedMarkdown = StripHtmlComments(markdown);
66+
67+
// Regex breakdown:
68+
// - (?:\n|^)\s*#*\s* - start of line, optional whitespace and markdown headers
69+
// - release[\s-]?notes? - matches "release note", "release notes", "release-note", "release-notes", etc.
70+
// - [:\s-]* - matches separator after "release note" (colon, dash, whitespace) but NOT other non-word chars like {
71+
// - (.*?) - lazily capture the release note content
72+
// - Terminator: double newline, end of string, or new markdown header
73+
var match = ReleaseNoteRegex().Match(cleanedMarkdown);
74+
75+
if (match.Success && match.Groups.Count > 1)
76+
{
77+
var releaseNote = match.Groups[1].Value.Trim();
78+
return string.IsNullOrWhiteSpace(releaseNote) ? null : releaseNote;
79+
}
80+
81+
return null;
82+
}
83+
84+
/// <summary>
85+
/// Extracts release notes from PR body and determines how to use them.
86+
/// </summary>
87+
/// <param name="prBody">The PR description body</param>
88+
/// <returns>
89+
/// A tuple where:
90+
/// - Item1: The title to use (either original title or extracted release note if short)
91+
/// - Item2: The description to use (extracted release note if long, otherwise null)
92+
/// </returns>
93+
public static (string? title, string? description) ExtractReleaseNotes(string? prBody)
94+
{
95+
var releaseNote = FindReleaseNote(prBody);
96+
97+
// No release note found: return nulls (use defaults)
98+
if (string.IsNullOrWhiteSpace(releaseNote))
99+
{
100+
return (null, null);
101+
}
102+
103+
// Long release note (>120 characters or multi-line): use in description
104+
if (releaseNote.Length > MaxReleaseNoteTitleLength || releaseNote.Contains('\n'))
105+
{
106+
return (null, releaseNote);
107+
}
108+
109+
// Short release note (≤120 characters, single line): use in title
110+
return (releaseNote, null);
111+
}
112+
}

src/services/Elastic.Documentation.Services/ChangelogService.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ Cancel ctx
147147
Output = input.Output,
148148
Config = input.Config,
149149
UsePrNumber = input.UsePrNumber,
150-
StripTitlePrefix = input.StripTitlePrefix
150+
StripTitlePrefix = input.StripTitlePrefix,
151+
ExtractReleaseNotes = input.ExtractReleaseNotes
151152
};
152153

153154
// Process this PR (treat as single PR)
@@ -238,6 +239,26 @@ Cancel ctx
238239
return true;
239240
}
240241

242+
// Extract release notes from PR body if requested
243+
if (input.ExtractReleaseNotes)
244+
{
245+
var (releaseNoteTitle, releaseNoteDescription) = ReleaseNotesExtractor.ExtractReleaseNotes(prInfo.Body);
246+
247+
// Use short release note as title if title was not explicitly provided
248+
if (releaseNoteTitle != null && string.IsNullOrWhiteSpace(input.Title))
249+
{
250+
input.Title = releaseNoteTitle;
251+
_logger.LogInformation("Using extracted release note as title: {Title}", input.Title);
252+
}
253+
254+
// Use long release note as description if description was not explicitly provided
255+
if (releaseNoteDescription != null && string.IsNullOrWhiteSpace(input.Description))
256+
{
257+
input.Description = releaseNoteDescription;
258+
_logger.LogInformation("Using extracted release note as description (length: {Length} characters)", releaseNoteDescription.Length);
259+
}
260+
}
261+
241262
// Use PR title if title was not explicitly provided
242263
if (string.IsNullOrWhiteSpace(input.Title))
243264
{

src/tooling/docs-builder/Commands/ChangelogCommand.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ public Task<int> Default()
5252
/// <param name="config">Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml'</param>
5353
/// <param name="usePrNumber">Optional: Use the PR number as the filename instead of generating it from a unique ID and title</param>
5454
/// <param name="stripTitlePrefix">Optional: When used with --prs, remove square brackets and text within them from the beginning of PR titles (e.g., "[Inference API] Title" becomes "Title")</param>
55+
/// <param name="extractReleaseNotes">Optional: When used with --prs, extract release notes from PR descriptions. Short release notes (≤120 characters, single line) are used as the title, long release notes (>120 characters or multi-line) are used as the description. Looks for content in formats like "Release Notes: ...", "Release-Notes: ...", "## Release Note", etc.</param>
5556
/// <param name="ctx"></param>
5657
[Command("add")]
5758
public async Task<int> Create(
@@ -73,6 +74,7 @@ public async Task<int> Create(
7374
string? config = null,
7475
bool usePrNumber = false,
7576
bool stripTitlePrefix = false,
77+
bool extractReleaseNotes = false,
7678
Cancel ctx = default
7779
)
7880
{
@@ -139,7 +141,8 @@ public async Task<int> Create(
139141
Output = output,
140142
Config = config,
141143
UsePrNumber = usePrNumber,
142-
StripTitlePrefix = stripTitlePrefix
144+
StripTitlePrefix = stripTitlePrefix,
145+
ExtractReleaseNotes = extractReleaseNotes
143146
};
144147

145148
serviceInvoker.AddCommand(service, input,

0 commit comments

Comments
 (0)