Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/cli/release/changelog-add.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ docs-builder changelog add [options...] [-h|--help]
: For example, if a PR title is `"[Attack discovery] Improves Attack discovery hallucination detection"`, the changelog title will be `"Improves Attack discovery hallucination detection"`.
: This option applies only when the title is derived from the PR (when `--title` is not explicitly provided).

`--extract-release-notes`
: Optional: When used with `--prs`, extract release notes from PR descriptions and use them in the changelog.
: The extractor looks for content in various formats in the PR description:
: - `Release Notes: ...`
: - `Release-Notes: ...`
: - `release notes: ...`
: - `Release Note: ...`
: - `Release Notes - ...`
: - `## Release Note` (as a markdown header)
: Short release notes (≤120 characters, single line) are used as the changelog title (only if `--title` is not explicitly provided).
: Long release notes (>120 characters or multi-line) are used as the changelog description (only if `--description` is not explicitly provided).
: If no release note is found, no changes are made to the title or description.

`--subtype <string?>`
: Optional: Subtype for breaking changes (for example, `api`, `behavioral`, or `configuration`).
: The valid subtypes are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs).
Expand Down
56 changes: 56 additions & 0 deletions docs/contribute/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,62 @@ The `--strip-title-prefix` option in this example means that if the PR title has
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.
:::

#### Extract release notes from PR descriptions [example-extract-release-notes]

You can use the `--extract-release-notes` option to automatically extract release notes from PR descriptions and use them in your changelog.

The extractor looks for release notes in various formats in the PR description:

- `Release Notes: This is the extracted sentence.`
- `Release-Notes: This is the extracted sentence.`
- `release notes: This is the extracted sentence.`
- `Release Note: This is the extracted sentence.`
- `Release Notes - This is the extracted sentence.`
- `## Release Note` (as a markdown header)

The extracted content is handled differently based on its length:

- **Short release notes (≤120 characters, single line)**: Used as the changelog title (only if `--title` is not explicitly provided)
- **Long release notes (>120 characters or multi-line)**: Used as the changelog description (only if `--description` is not explicitly provided)
- **No release note found**: No changes are made to the title or description

Example PR description:

```markdown
## Summary

This PR adds support for new aggregation types.

## Release Notes: Adds support for new aggregation types including date histogram and range aggregations

## Testing

Unit tests included.
```

When you run:

```sh
docs-builder changelog add \
--prs https://github.com/elastic/elasticsearch/pull/123456 \
--products "elasticsearch 9.2.3" \
--extract-release-notes
```

The changelog will use "Adds support for new aggregation types including date histogram and range aggregations" as the description (since it's longer than 120 characters).

If the release note is shorter, for example:

```markdown
## Release Notes: Adds support for new aggregation types
```

The changelog will use "Adds support for new aggregation types" as the title instead.

:::{note}
The `--extract-release-notes` option only applies when used with `--prs`. If you explicitly provide `--title` or `--description`, those values take precedence over extracted release notes.
:::

#### Block changelog creation with PR labels [example-block-label]

You can configure product-specific label blockers to prevent changelog creation for certain PRs based on their labels.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ public class ChangelogInput
public string? Config { get; set; }
public bool UsePrNumber { get; set; }
public bool StripTitlePrefix { get; set; }
public bool ExtractReleaseNotes { get; set; }
}

Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ static GitHubPrService()
return new GitHubPrInfo
{
Title = prData.Title,
Body = prData.Body ?? string.Empty,
Labels = prData.Labels?.Select(l => l.Name).ToArray() ?? []
};
}
Expand Down Expand Up @@ -140,6 +141,7 @@ private static (string? owner, string? repo, int? prNumber) ParsePrUrl(string pr
private sealed class GitHubPrResponse
{
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public List<GitHubLabel>? Labels { get; set; }
}

Expand All @@ -161,6 +163,7 @@ private sealed partial class GitHubPrJsonContext : JsonSerializerContext;
public class GitHubPrInfo
{
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public string[] Labels { get; set; } = [];
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// 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.Text.RegularExpressions;

namespace Elastic.Documentation.Services.Changelog;

/// <summary>
/// Utility class for extracting release notes from PR descriptions
/// </summary>
public static partial class ReleaseNotesExtractor
{
[GeneratedRegex(@"<!--[\s\S]*?-->", RegexOptions.None)]
private static partial Regex HtmlCommentRegex();

[GeneratedRegex(@"(\r?\n){3,}", RegexOptions.None)]
private static partial Regex MultipleNewlinesRegex();

[GeneratedRegex(@"(?:\n|^)\s*#*\s*release[\s-]?notes?[:\s-]*(.*?)(?:(\r?\n|\r){2}|$|((\r?\n|\r)\s*#+))", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
private static partial Regex ReleaseNoteRegex();

/// <summary>
/// Strips HTML comments from markdown text.
/// This handles both single-line and multi-line comments.
/// Also collapses excessive blank lines that may result from comment removal,
/// to prevent creating artificial section breaks.
/// </summary>
private static string StripHtmlComments(string markdown)
{
if (string.IsNullOrWhiteSpace(markdown))
{
return markdown;
}

// Remove HTML comments
var withoutComments = HtmlCommentRegex().Replace(markdown, string.Empty);

// Collapse 3+ consecutive newlines into 2 (preserving paragraph breaks but not creating extra ones)
var normalized = MultipleNewlinesRegex().Replace(withoutComments, "\n\n");

return normalized;
}

/// <summary>
/// Finds and retrieves the actual "release note" details from a PR description (in markdown format).
/// It will look for:
/// - paragraphs beginning with "release note" (or slight variations of that) and the sentence till the end of line.
/// - markdown headers like "## Release Note"
///
/// HTML comments are stripped before extraction to avoid picking up template instructions.
/// </summary>
/// <param name="markdown">The PR description body</param>
/// <returns>The extracted release note content, or null if not found</returns>
public static string? FindReleaseNote(string? markdown)
{
if (string.IsNullOrWhiteSpace(markdown))
{
return null;
}

// Strip HTML comments first to avoid extracting template instructions
var cleanedMarkdown = StripHtmlComments(markdown);

// Regex breakdown:
// - (?:\n|^)\s*#*\s* - start of line, optional whitespace and markdown headers
// - release[\s-]?notes? - matches "release note", "release notes", "release-note", "release-notes", etc.
// - [:\s-]* - matches separator after "release note" (colon, dash, whitespace) but NOT other non-word chars like {
// - (.*?) - lazily capture the release note content
// - Terminator: double newline, end of string, or new markdown header
var match = ReleaseNoteRegex().Match(cleanedMarkdown);

if (match.Success && match.Groups.Count > 1)
{
var releaseNote = match.Groups[1].Value.Trim();
return string.IsNullOrWhiteSpace(releaseNote) ? null : releaseNote;
}

return null;
}

/// <summary>
/// Extracts release notes from PR body and determines how to use them.
/// </summary>
/// <param name="prBody">The PR description body</param>
/// <returns>
/// A tuple where:
/// - Item1: The title to use (either original title or extracted release note if short)
/// - Item2: The description to use (extracted release note if long, otherwise null)
/// </returns>
public static (string? title, string? description) ExtractReleaseNotes(string? prBody)
{
var releaseNote = FindReleaseNote(prBody);

// No release note found: return nulls (use defaults)
if (string.IsNullOrWhiteSpace(releaseNote))
{
return (null, null);
}

// Long release note (>120 characters or multi-line): use in description
if (releaseNote.Length > 120 || releaseNote.Contains('\n'))
{
return (null, releaseNote);
}

// Short release note (≤120 characters, single line): use in title
return (releaseNote, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ Cancel ctx
Output = input.Output,
Config = input.Config,
UsePrNumber = input.UsePrNumber,
StripTitlePrefix = input.StripTitlePrefix
StripTitlePrefix = input.StripTitlePrefix,
ExtractReleaseNotes = input.ExtractReleaseNotes
};

// Process this PR (treat as single PR)
Expand Down Expand Up @@ -238,6 +239,26 @@ Cancel ctx
return true;
}

// Extract release notes from PR body if requested
if (input.ExtractReleaseNotes)
{
var (releaseNoteTitle, releaseNoteDescription) = ReleaseNotesExtractor.ExtractReleaseNotes(prInfo.Body);

// Use short release note as title if title was not explicitly provided
if (releaseNoteTitle != null && string.IsNullOrWhiteSpace(input.Title))
{
input.Title = releaseNoteTitle;
_logger.LogInformation("Using extracted release note as title: {Title}", input.Title);
}

// Use long release note as description if description was not explicitly provided
if (releaseNoteDescription != null && string.IsNullOrWhiteSpace(input.Description))
{
input.Description = releaseNoteDescription;
_logger.LogInformation("Using extracted release note as description (length: {Length} characters)", releaseNoteDescription.Length);
}
}

// Use PR title if title was not explicitly provided
if (string.IsNullOrWhiteSpace(input.Title))
{
Expand Down
5 changes: 4 additions & 1 deletion src/tooling/docs-builder/Commands/ChangelogCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public Task<int> Default()
/// <param name="config">Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml'</param>
/// <param name="usePrNumber">Optional: Use the PR number as the filename instead of generating it from a unique ID and title</param>
/// <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>
/// <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>
/// <param name="ctx"></param>
[Command("add")]
public async Task<int> Create(
Expand All @@ -73,6 +74,7 @@ public async Task<int> Create(
string? config = null,
bool usePrNumber = false,
bool stripTitlePrefix = false,
bool extractReleaseNotes = false,
Cancel ctx = default
)
{
Expand Down Expand Up @@ -139,7 +141,8 @@ public async Task<int> Create(
Output = output,
Config = config,
UsePrNumber = usePrNumber,
StripTitlePrefix = stripTitlePrefix
StripTitlePrefix = stripTitlePrefix,
ExtractReleaseNotes = extractReleaseNotes
};

serviceInvoker.AddCommand(service, input,
Expand Down
Loading
Loading