diff --git a/docs/cli/release/changelog-add.md b/docs/cli/release/changelog-add.md index 5d02b5c94..e00f1e003 100644 --- a/docs/cli/release/changelog-add.md +++ b/docs/cli/release/changelog-add.md @@ -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 ` : 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). diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index b1218febb..380dd4756 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -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. diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs index 63b0c8f9f..d3bb737a4 100644 --- a/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs @@ -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; } } diff --git a/src/services/Elastic.Documentation.Services/Changelog/GitHubPrService.cs b/src/services/Elastic.Documentation.Services/Changelog/GitHubPrService.cs index 189270d58..24f7e5d4c 100644 --- a/src/services/Elastic.Documentation.Services/Changelog/GitHubPrService.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/GitHubPrService.cs @@ -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() ?? [] }; } @@ -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? Labels { get; set; } } @@ -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; } = []; } diff --git a/src/services/Elastic.Documentation.Services/Changelog/ReleaseNotesExtractor.cs b/src/services/Elastic.Documentation.Services/Changelog/ReleaseNotesExtractor.cs new file mode 100644 index 000000000..3f59c778e --- /dev/null +++ b/src/services/Elastic.Documentation.Services/Changelog/ReleaseNotesExtractor.cs @@ -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; + +/// +/// Utility class for extracting release notes from PR descriptions +/// +public static partial class ReleaseNotesExtractor +{ + [GeneratedRegex(@"", 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(); + + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + /// The PR description body + /// The extracted release note content, or null if not found + 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; + } + + /// + /// Extracts release notes from PR body and determines how to use them. + /// + /// The PR description body + /// + /// 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) + /// + 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); + } +} diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index 350020325..1a945252b 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -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) @@ -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)) { diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 4d5b0f702..f9f67d5c1 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -52,6 +52,7 @@ public Task Default() /// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' /// Optional: Use the PR number as the filename instead of generating it from a unique ID and title /// 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") + /// 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. /// [Command("add")] public async Task Create( @@ -73,6 +74,7 @@ public async Task Create( string? config = null, bool usePrNumber = false, bool stripTitlePrefix = false, + bool extractReleaseNotes = false, Cancel ctx = default ) { @@ -139,7 +141,8 @@ public async Task Create( Output = output, Config = config, UsePrNumber = usePrNumber, - StripTitlePrefix = stripTitlePrefix + StripTitlePrefix = stripTitlePrefix, + ExtractReleaseNotes = extractReleaseNotes }; serviceInvoker.AddCommand(service, input, diff --git a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs index dedb25d47..a15eb51ef 100644 --- a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs +++ b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs @@ -5406,6 +5406,422 @@ public async Task RenderChangelogs_WithAsciidocFileType_ValidatesAsciidocFormat( asciidocContent.Should().NotContain("###", "should not contain markdown headers"); } + [Fact] + public async Task CreateChangelog_WithExtractReleaseNotes_ShortReleaseNote_UsesAsTitle() + { + // Arrange + var mockGitHubService = A.Fake(); + var prInfo = new GitHubPrInfo + { + Title = "Implement new aggregation API", + Body = "## Summary\n\nThis PR adds a new feature.\n\nRelease Notes: Adds support for new aggregation types", + Labels = ["type:feature"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._)) + .Returns(prInfo); + + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + - bug-fix + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + label_to_type: + "type:feature": feature + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()), + ExtractReleaseNotes = true + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(1); + + var yamlContent = await 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*(#|$)"); + } + } + + [Fact] + public async Task CreateChangelog_WithExtractReleaseNotes_LongReleaseNote_UsesAsDescription() + { + // Arrange + var mockGitHubService = A.Fake(); + var longReleaseNote = "Adds support for new aggregation types including date histogram, range aggregations, and nested aggregations with improved performance"; + var prInfo = new GitHubPrInfo + { + Title = "Implement new aggregation API", + Body = $"## Summary\n\nThis PR adds a new feature.\n\nRelease Notes: {longReleaseNote}", + Labels = ["type:feature"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._)) + .Returns(prInfo); + + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + - bug-fix + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + label_to_type: + "type:feature": feature + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()), + ExtractReleaseNotes = true + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(1); + + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("title: Implement new aggregation API"); + yamlContent.Should().Contain($"description: {longReleaseNote}"); + } + + [Fact] + public async Task CreateChangelog_WithExtractReleaseNotes_MultiLineReleaseNote_UsesAsDescription() + { + // Arrange + var mockGitHubService = A.Fake(); + // The regex stops at double newline, so we need a release note that spans multiple lines without double newline + var multiLineReleaseNote = "Adds support for new aggregation types\nThis includes date histogram and range aggregations\nwith improved performance"; + var prInfo = new GitHubPrInfo + { + Title = "Implement new aggregation API", + Body = $"## Summary\n\nThis PR adds a new feature.\n\nRelease Notes: {multiLineReleaseNote}", + Labels = ["type:feature"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._)) + .Returns(prInfo); + + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + - bug-fix + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + label_to_type: + "type:feature": feature + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()), + ExtractReleaseNotes = true + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(1); + + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("title: Implement new aggregation API"); + yamlContent.Should().Contain("description:"); + yamlContent.Should().Contain("Adds support for new aggregation types"); + yamlContent.Should().Contain("date histogram"); + } + + [Fact] + public async Task CreateChangelog_WithExtractReleaseNotes_NoReleaseNote_UsesPrTitle() + { + // Arrange + var mockGitHubService = A.Fake(); + var prInfo = new GitHubPrInfo + { + Title = "Implement new aggregation API", + Body = "## Summary\n\nThis PR adds a new feature but has no release notes section.", + Labels = ["type:feature"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._)) + .Returns(prInfo); + + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + - bug-fix + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + label_to_type: + "type:feature": feature + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()), + ExtractReleaseNotes = true + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(1); + + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("title: Implement new aggregation API"); + // Description should not be set when no release note is found + 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*(#|$)"); + } + } + + [Fact] + public async Task CreateChangelog_WithExtractReleaseNotes_ExplicitTitle_TakesPrecedence() + { + // Arrange + var mockGitHubService = A.Fake(); + var prInfo = new GitHubPrInfo + { + Title = "Implement new aggregation API", + Body = "Release Notes: Adds support for new aggregation types", + Labels = ["type:feature"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._)) + .Returns(prInfo); + + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + - bug-fix + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + label_to_type: + "type:feature": feature + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()), + ExtractReleaseNotes = true, + Title = "Custom title" + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(1); + + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("title: Custom title"); + yamlContent.Should().NotContain("Adds support for new aggregation types"); + } + + [Fact] + public async Task CreateChangelog_WithExtractReleaseNotes_ExplicitDescription_TakesPrecedence() + { + // Arrange + var mockGitHubService = A.Fake(); + var longReleaseNote = "Adds support for new aggregation types including date histogram, range aggregations, and nested aggregations with improved performance"; + var prInfo = new GitHubPrInfo + { + Title = "Implement new aggregation API", + Body = $"Release Notes: {longReleaseNote}", + Labels = ["type:feature"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._)) + .Returns(prInfo); + + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + - bug-fix + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + label_to_type: + "type:feature": feature + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()), + ExtractReleaseNotes = true, + Description = "Custom description" + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(1); + + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("description: Custom description"); + yamlContent.Should().NotContain(longReleaseNote); + } + [SuppressMessage("Security", "CA5350:Do not use insecure cryptographic algorithm SHA1", Justification = "SHA1 is required for compatibility with existing changelog bundle format")] private static string ComputeSha1(string content) { diff --git a/tests/Elastic.Documentation.Services.Tests/ReleaseNotesExtractorTests.cs b/tests/Elastic.Documentation.Services.Tests/ReleaseNotesExtractorTests.cs new file mode 100644 index 000000000..7ef1e9459 --- /dev/null +++ b/tests/Elastic.Documentation.Services.Tests/ReleaseNotesExtractorTests.cs @@ -0,0 +1,243 @@ +// 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.Diagnostics.CodeAnalysis; +using Elastic.Documentation.Services.Changelog; +using FluentAssertions; + +namespace Elastic.Documentation.Services.Tests; + +[SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Test method names with underscores are standard in xUnit")] +public class ReleaseNotesExtractorTests +{ + [Fact] + public void FindReleaseNote_WithReleaseNotesColon_ExtractsContent() + { + // Arrange + var prBody = "## Summary\n\nThis PR adds a new feature.\n\nRelease Notes: Adds support for new aggregation types"; + + // Act + var result = ReleaseNotesExtractor.FindReleaseNote(prBody); + + // Assert + result.Should().Be("Adds support for new aggregation types"); + } + + [Fact] + public void FindReleaseNote_WithReleaseNotesDash_ExtractsContent() + { + // Arrange + var prBody = "## Summary\n\nRelease Notes - Adds support for new aggregation types"; + + // Act + var result = ReleaseNotesExtractor.FindReleaseNote(prBody); + + // Assert + result.Should().Be("Adds support for new aggregation types"); + } + + [Fact] + public void FindReleaseNote_WithReleaseNoteSingular_ExtractsContent() + { + // Arrange + var prBody = "## Summary\n\nRelease Note: Adds support for new aggregation types"; + + // Act + var result = ReleaseNotesExtractor.FindReleaseNote(prBody); + + // Assert + result.Should().Be("Adds support for new aggregation types"); + } + + [Fact] + public void FindReleaseNote_WithMarkdownHeader_ExtractsContent() + { + // Arrange + var prBody = "## Summary\n\n## Release Note\n\nAdds support for new aggregation types"; + + // Act + var result = ReleaseNotesExtractor.FindReleaseNote(prBody); + + // Assert + result.Should().Be("Adds support for new aggregation types"); + } + + [Fact] + public void FindReleaseNote_WithCaseVariations_ExtractsContent() + { + // Arrange + var prBody = "release notes: Adds support for new aggregation types"; + + // Act + var result = ReleaseNotesExtractor.FindReleaseNote(prBody); + + // Assert + result.Should().Be("Adds support for new aggregation types"); + } + + [Fact] + public void FindReleaseNote_WithHyphenatedFormat_ExtractsContent() + { + // Arrange + var prBody = "Release-Notes: Adds support for new aggregation types"; + + // Act + var result = ReleaseNotesExtractor.FindReleaseNote(prBody); + + // Assert + result.Should().Be("Adds support for new aggregation types"); + } + + [Fact] + public void FindReleaseNote_WithHtmlComments_StripsComments() + { + // Arrange + var prBody = "\nRelease Notes: Adds support for new aggregation types\n"; + + // Act + var result = ReleaseNotesExtractor.FindReleaseNote(prBody); + + // Assert + result.Should().Be("Adds support for new aggregation types"); + } + + [Fact] + public void FindReleaseNote_WithNoReleaseNote_ReturnsNull() + { + // Arrange + var prBody = "## Summary\n\nThis PR adds a new feature but has no release notes section."; + + // Act + var result = ReleaseNotesExtractor.FindReleaseNote(prBody); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void FindReleaseNote_WithEmptyBody_ReturnsNull() + { + // Arrange + var prBody = ""; + + // Act + var result = ReleaseNotesExtractor.FindReleaseNote(prBody); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void FindReleaseNote_WithNullBody_ReturnsNull() + { + // Arrange + string? prBody = null; + + // Act + var result = ReleaseNotesExtractor.FindReleaseNote(prBody); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void FindReleaseNote_WithMultiLineReleaseNote_ExtractsUntilDoubleNewline() + { + // Arrange + var prBody = "Release Notes: This is a multi-line\nrelease note that spans\nmultiple lines\n\n## Next Section"; + + // Act + var result = ReleaseNotesExtractor.FindReleaseNote(prBody); + + // Assert + result.Should().Be("This is a multi-line\nrelease note that spans\nmultiple lines"); + } + + [Fact] + public void ExtractReleaseNotes_WithShortReleaseNote_ReturnsAsTitle() + { + // Arrange + var prBody = "Release Notes: Adds support for new aggregation types"; + + // 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 + 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 + var prBody = "Release Notes: Adds support for new aggregation types\nThis includes date histogram and range aggregations\nwith improved performance"; + + // 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 + 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 + var prBody = "Release Notes: " + new string('a', 121); + + // Act + var (title, description) = ReleaseNotesExtractor.ExtractReleaseNotes(prBody); + + // Assert + title.Should().BeNull(); + description.Should().Be(new string('a', 121)); + } + + [Fact] + public void ExtractReleaseNotes_WithNoReleaseNote_ReturnsNulls() + { + // Arrange + var prBody = "## Summary\n\nThis PR has no release notes."; + + // Act + var (title, description) = ReleaseNotesExtractor.ExtractReleaseNotes(prBody); + + // Assert + title.Should().BeNull(); + description.Should().BeNull(); + } +}