Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 1 addition & 2 deletions docs/cli/release/changelog-add.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ docs-builder changelog add [options...] [-h|--help]
: - `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).
: Matched release note text is used as the changelog description (only if `--description` is not explicitly provided). The changelog title is always taken from `--title` or from the PR or issue title, not from the release note section.
: By default, the behavior is determined by the `extract.release_notes` changelog configuration setting.

`--feature-id <string?>`
Expand Down
7 changes: 3 additions & 4 deletions docs/contribute/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -530,11 +530,10 @@ In particular, it looks for content in these formats in the PR description:
- `Release Notes - This is the extracted sentence.`
- `## Release Note` (as a markdown header)

The extracted content is handled differently based on its length:
How the extracted text is used:

- **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
- **Release note found**: The extracted text is used as the changelog description (only if `--description` is not explicitly provided). The changelog title comes from `--title` or the PR title, not from the release note section.
- **No release note found**: No description is filled from the PR body; the title still comes from `--title` or the PR title as usual.

:::{note}
If you explicitly provide `--title` or `--description`, those values take precedence over extracted release notes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public record CreateChangelogArguments
public bool UseIssueNumber { get; init; }
public bool? StripTitlePrefix { get; init; }
/// <summary>
/// Whether to extract release notes from PR/issue descriptions. null = use config default.
/// Whether to extract release note text from PR/issue descriptions for the entry description. null = use config default.
/// </summary>
public bool? ExtractReleaseNotes { get; init; }

Expand Down
17 changes: 5 additions & 12 deletions src/services/Elastic.Changelog/Creation/IssueInfoProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,22 +102,15 @@ public async Task<IssueProcessingResult> ProcessIssueAsync(

if (input.ExtractReleaseNotes ?? false)
{
var (releaseNoteTitle, releaseNoteDescription) = ReleaseNotesExtractor.ExtractReleaseNotes(issueInfo.Body);

if (releaseNoteTitle != null && string.IsNullOrWhiteSpace(input.Title))
{
derived.Title = releaseNoteTitle;
logger.LogInformation("Using extracted release note as title: {Title}", derived.Title);
}

if (releaseNoteDescription != null && string.IsNullOrWhiteSpace(input.Description))
var releaseNote = ReleaseNotesExtractor.FindReleaseNote(issueInfo.Body);
if (releaseNote != null && string.IsNullOrWhiteSpace(input.Description))
{
derived.Description = releaseNoteDescription;
logger.LogInformation("Using extracted release note as description (length: {Length} characters)", releaseNoteDescription.Length);
derived.Description = releaseNote;
logger.LogInformation("Using extracted release note as description (length: {Length} characters)", releaseNote.Length);
}
}

if (string.IsNullOrWhiteSpace(input.Title) && derived.Title == null)
if (string.IsNullOrWhiteSpace(input.Title))
{
if (string.IsNullOrWhiteSpace(issueInfo.Title))
{
Expand Down
23 changes: 7 additions & 16 deletions src/services/Elastic.Changelog/Creation/PrInfoProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,28 +102,19 @@ public async Task<PrProcessingResult> ProcessPrAsync(
{
var derived = new DerivedPrFields();

// Extract release notes from PR body if requested
// Extract release note text from PR body for description if requested
if (input.ExtractReleaseNotes ?? false)
{
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))
{
derived.Title = releaseNoteTitle;
logger.LogInformation("Using extracted release note as title: {Title}", derived.Title);
}

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

// Use PR title if title was not explicitly provided and not already derived
if (string.IsNullOrWhiteSpace(input.Title) && derived.Title == null)
// Use PR title if title was not explicitly provided
if (string.IsNullOrWhiteSpace(input.Title))
{
if (string.IsNullOrWhiteSpace(prInfo.Title))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,33 +80,23 @@ public async Task<bool> EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr
return await SetOutputs(PrEvaluationResult.Skipped);
}

// Resolve title: prefer release notes from PR body, fall back to PR title
// Resolve title from PR title only (release note text is never used as title)
var prTitle = input.PrTitle;
if (input.StripTitlePrefix)
prTitle = ChangelogTextUtilities.StripSquareBracketPrefix(prTitle);

string? title = null;
string? description = null;

if (config.Extract.ReleaseNotes && !string.IsNullOrWhiteSpace(input.PrBody))
{
var (releaseNoteTitle, releaseNoteDescription) = ReleaseNotesExtractor.ExtractReleaseNotes(input.PrBody);

if (releaseNoteTitle != null)
{
title = releaseNoteTitle;
_logger.LogInformation("Using extracted release note as title: {Title}", title);
}

if (releaseNoteDescription != null)
var releaseNote = ReleaseNotesExtractor.FindReleaseNote(input.PrBody);
if (releaseNote != null)
{
description = releaseNoteDescription;
description = releaseNote;
_logger.LogInformation("Using extracted release note as description (length: {Length} characters)", description.Length);
Comment thread
lcawl marked this conversation as resolved.
}
}

// Fall back to PR title when no short release note was found
title ??= prTitle;
var title = prTitle;

if (string.IsNullOrWhiteSpace(title))
{
Expand Down
27 changes: 0 additions & 27 deletions src/services/Elastic.Changelog/ReleaseNotesExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ public static partial class ReleaseNotesExtractor
[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();

private const int MaxReleaseNoteTitleLength = 120;

/// <summary>
/// Strips HTML comments from markdown text.
/// This handles both single-line and multi-line comments.
Expand Down Expand Up @@ -76,29 +74,4 @@ private static string StripHtmlComments(string markdown)

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 > MaxReleaseNoteTitleLength || 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 @@ -359,7 +359,8 @@ internal record BundleProfileYaml
internal record ExtractConfigurationYaml
{
/// <summary>
/// Whether to extract release notes from PR descriptions by default.
/// Whether to extract release note text from PR or issue descriptions for the changelog entry description by default.
/// Does not affect the title (title comes from <c>--title</c> or the PR/issue title).
/// Defaults to true.
/// </summary>
public bool? ReleaseNotes { get; set; }
Expand Down
2 changes: 1 addition & 1 deletion src/tooling/docs-builder/Commands/ChangelogCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ public Task<int> Init(
/// <param name="areas">Optional: Area(s) affected (comma-separated or specify multiple times)</param>
/// <param name="config">Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml'</param>
/// <param name="description">Optional: Additional information about the change (max 600 characters)</param>
/// <param name="noExtractReleaseNotes">Optional: Turn off extraction of release notes from PR descriptions. By default, release notes are extracted when using --prs. 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.</param>
/// <param name="noExtractReleaseNotes">Optional: Turn off extraction of release notes from PR descriptions. By default, release notes are extracted when using --prs. Matched release note text is used as the changelog description (only if --description is not explicitly provided). The changelog title comes from --title or the PR title, not from the release note section.</param>
/// <param name="noExtractIssues">Optional: Turn off extraction of linked references. When using --prs: turns off extraction of linked issues from the PR body (e.g., "Fixes #123"). When using --issues: turns off extraction of linked PRs from the issue body (e.g., "Fixed by #123"). By default, linked references are extracted in both cases.</param>
/// <param name="featureId">Optional: Feature flag ID</param>
/// <param name="highlight">Optional: Include in release highlights</param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace Elastic.Changelog.Tests.Changelogs.Create;
public class ReleaseNoteExtractionTests(ITestOutputHelper output) : CreateChangelogTestBase(output)
{
[Fact]
public async Task CreateChangelog_WithExtractReleaseNotes_ShortReleaseNote_UsesAsTitle()
public async Task CreateChangelog_WithExtractReleaseNotes_ShortReleaseNote_UsesPrTitleAndDescription()
{
// Arrange
var prInfo = new GitHubPrInfo
Expand Down Expand Up @@ -69,14 +69,8 @@ public async Task CreateChangelog_WithExtractReleaseNotes_ShortReleaseNote_UsesA
files.Should().HaveCount(1);

var yamlContent = await FileSystem.File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken);
yamlContent.Should().Contain("title: Adds support for new aggregation types");
// Description should not be set when release note is used as title
if (yamlContent.Contains("description:"))
{
// If description field exists, it should be empty or commented out
var descriptionLine = yamlContent.Split('\n').FirstOrDefault(l => l.Contains("description:"));
descriptionLine.Should().MatchRegex(@"description:\s*(#|$)");
}
yamlContent.Should().Contain("title: Implement new aggregation API");
yamlContent.Should().Contain("description: Adds support for new aggregation types");
}

[Fact]
Expand Down Expand Up @@ -337,7 +331,7 @@ public async Task CreateChangelog_WithExtractReleaseNotes_ExplicitTitle_TakesPre

var yamlContent = await FileSystem.File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken);
yamlContent.Should().Contain("title: Custom title");
yamlContent.Should().NotContain("Adds support for new aggregation types");
yamlContent.Should().Contain("description: Adds support for new aggregation types");
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ public async Task EvaluatePr_WithoutProductLabels_OutputsProductLabelTable()
}

[Fact]
public async Task EvaluatePr_ShortReleaseNote_OverridesPrTitle()
public async Task EvaluatePr_ShortReleaseNote_UsesPrTitleAndDescription()
{
await WriteMinimalConfig();
var service = CreateService();
Expand All @@ -527,8 +527,8 @@ public async Task EvaluatePr_ShortReleaseNote_OverridesPrTitle()
var result = await service.EvaluatePr(Collector, args, CancellationToken.None);

result.Should().BeTrue();
VerifyOutputSet("title", "Added new search API endpoint");
A.CallTo(() => _mockCore.SetOutputAsync("description", A<string>._)).MustNotHaveHappened();
VerifyOutputSet("title", "Some PR title");
VerifyOutputSet("description", "Added new search API endpoint");
}

[Fact]
Expand Down Expand Up @@ -612,7 +612,7 @@ public async Task EvaluatePr_ExtractionDisabled_IgnoresReleaseNote()
}

[Fact]
public async Task EvaluatePr_ReleaseNoteHeader_ExtractedAsTitle()
public async Task EvaluatePr_ReleaseNoteHeader_ExtractedAsDescription_PrTitleAsTitle()
{
await WriteMinimalConfig();
var service = CreateService();
Expand All @@ -630,6 +630,7 @@ New aggregation pipeline support
var result = await service.EvaluatePr(Collector, args, CancellationToken.None);

result.Should().BeTrue();
VerifyOutputSet("title", "New aggregation pipeline support");
VerifyOutputSet("title", "Some PR title");
VerifyOutputSet("description", "New aggregation pipeline support");
}
}
93 changes: 10 additions & 83 deletions tests/Elastic.Changelog.Tests/ReleaseNotesExtractorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,105 +203,32 @@ multiple lines
}

[Fact]
public void ExtractReleaseNotes_WithShortReleaseNote_ReturnsAsTitle()
public void FindReleaseNote_WithExactly120Characters_ReturnsContent()
{
// Arrange
// language=markdown
var prBody = "Release Notes: Adds support for new aggregation types";
var expected = new string('a', 120);
var prBody = "Release Notes: " + expected;

// Act
var (title, description) = ReleaseNotesExtractor.ExtractReleaseNotes(prBody);

// Assert
title.Should().Be("Adds support for new aggregation types");
description.Should().BeNull();
}

[Fact]
public void ExtractReleaseNotes_WithLongReleaseNote_ReturnsAsDescription()
{
// Arrange
// language=markdown
var prBody = "Release Notes: Adds support for new aggregation types including date histogram, range aggregations, and nested aggregations with improved performance";

// Act
var (title, description) = ReleaseNotesExtractor.ExtractReleaseNotes(prBody);

// Assert
title.Should().BeNull();
description.Should().Be("Adds support for new aggregation types including date histogram, range aggregations, and nested aggregations with improved performance");
}

[Fact]
public void ExtractReleaseNotes_WithMultiLineReleaseNote_ReturnsAsDescription()
{
// Arrange
// The regex stops at double newline, so we need a release note that spans multiple lines without double newline
// language=markdown
var prBody =
"""
Release Notes: Adds support for new aggregation types
This includes date histogram and range aggregations
with improved performance
""".ReplaceLineEndings("\n");

// Act
var (title, description) = ReleaseNotesExtractor.ExtractReleaseNotes(prBody);

// Assert
// Since there's a newline in the content, it should be treated as multi-line
title.Should().BeNull();
description.Should().Contain("Adds support for new aggregation types");
description.Should().Contain("\n");
}

[Fact]
public void ExtractReleaseNotes_WithExactly120Characters_ReturnsAsTitle()
{
// Arrange
// language=markdown
var prBody = "Release Notes: " + new string('a', 120);

// Act
var (title, description) = ReleaseNotesExtractor.ExtractReleaseNotes(prBody);

// Assert
title.Should().Be(new string('a', 120));
description.Should().BeNull();
}

[Fact]
public void ExtractReleaseNotes_With121Characters_ReturnsAsDescription()
{
// Arrange
// language=markdown
var prBody = "Release Notes: " + new string('a', 121);

// Act
var (title, description) = ReleaseNotesExtractor.ExtractReleaseNotes(prBody);
var result = ReleaseNotesExtractor.FindReleaseNote(prBody);

// Assert
title.Should().BeNull();
description.Should().Be(new string('a', 121));
result.Should().Be(expected);
}

[Fact]
public void ExtractReleaseNotes_WithNoReleaseNote_ReturnsNulls()
public void FindReleaseNote_With121Characters_ReturnsContent()
{
// Arrange
// language=markdown
var prBody =
"""
## Summary

This PR has no release notes.
""";
var expected = new string('a', 121);
var prBody = "Release Notes: " + expected;

// Act
var (title, description) = ReleaseNotesExtractor.ExtractReleaseNotes(prBody);
var result = ReleaseNotesExtractor.FindReleaseNote(prBody);

// Assert
title.Should().BeNull();
description.Should().BeNull();
result.Should().Be(expected);
}
}
Loading