diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 9298a52cc..ea39f9367 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -3927,6 +3927,236 @@ } ] }, + { + "path": [ + "changelog" + ], + "name": "note", + "summary": "Create a note-*.yml changelog fragment for items that have no associated PR.", + "notes": "Use this command for release notes that are not tied to a specific pull request \u2014 for example, a\nknown-issue entry or a cross-cutting change that spans many PRs. Every product listed must have a\nconcrete target (not a wildcard); --prs and --issues are still accepted as\noptional citations but do not affect the output filename.", + "usage": "docs-builder changelog note [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "name", + "type": "string", + "required": false, + "summary": "Explicit slug for the note filename. Defaults to a slug derived from the title." + }, + { + "role": "flag", + "name": "products", + "type": "string", + "required": false, + "summary": "Products in format \u0022product target lifecycle, ...\u0022 (for example, \u0022elasticsearch 9.2.0 ga\u0022). Target is required for all products." + }, + { + "role": "flag", + "name": "action", + "type": "string", + "required": false, + "summary": "Optional action text." + }, + { + "role": "flag", + "name": "areas", + "type": "array", + "required": false, + "summary": "Optional area tags.", + "repeatable": true, + "elementType": "string" + }, + { + "role": "flag", + "name": "concise", + "type": "boolean", + "required": false, + "summary": "Omit schema reference comments from the generated YAML.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "config", + "type": "string", + "required": false, + "summary": "Path to the changelog.yml configuration file.", + "validations": [ + { + "kind": "rejectSymbolicLinks" + }, + { + "kind": "existing" + }, + { + "kind": "fileExtensions", + "values": [ + "yml", + "yaml" + ] + } + ] + }, + { + "role": "flag", + "name": "description", + "type": "string", + "required": false, + "summary": "Entry description." + }, + { + "role": "flag", + "name": "no-extract-release-notes", + "type": "boolean", + "required": false, + "summary": "Skip extracting release note text from PR/issue descriptions.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "no-extract-issues", + "type": "boolean", + "required": false, + "summary": "Skip extracting linked issues/PRs from PR/issue body.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "feature-id", + "type": "string", + "required": false, + "summary": "Optional feature ID." + }, + { + "role": "flag", + "name": "highlight", + "type": "boolean", + "required": false, + "summary": "Mark the entry as a highlight.", + "defaultValue": "default" + }, + { + "role": "flag", + "name": "impact", + "type": "string", + "required": false, + "summary": "Optional impact text." + }, + { + "role": "flag", + "name": "issues", + "type": "array", + "required": false, + "summary": "Optional issue URLs (cited but not used as anchor).", + "repeatable": true, + "elementType": "string" + }, + { + "role": "flag", + "name": "owner", + "type": "string", + "required": false, + "summary": "GitHub owner. Falls back to bundle.owner or \u0022elastic\u0022." + }, + { + "role": "flag", + "name": "output", + "type": "string", + "required": false, + "summary": "Output directory." + }, + { + "role": "flag", + "name": "prs", + "type": "array", + "required": false, + "summary": "Optional PR URLs (cited but not used as anchor).", + "repeatable": true, + "elementType": "string" + }, + { + "role": "flag", + "name": "repo", + "type": "string", + "required": false, + "summary": "GitHub repository name." + }, + { + "role": "flag", + "name": "strip-title-prefix", + "type": "boolean", + "required": false, + "summary": "Strip a repo-name prefix from the title.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "strict-fetch", + "type": "boolean", + "required": false, + "summary": "Treat GitHub fetch failures as errors.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "subtype", + "type": "string", + "required": false, + "summary": "Entry subtype." + }, + { + "role": "flag", + "name": "title", + "type": "string", + "required": false, + "summary": "Entry title (required)." + }, + { + "role": "flag", + "name": "type", + "type": "string", + "required": false, + "summary": "Entry type (required)." + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, { "path": [ "changelog" diff --git a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs index 8333899d2..7a7b737f3 100644 --- a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs +++ b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs @@ -62,6 +62,9 @@ public record CreateChangelogArguments /// unauthorized GITHUB_TOKEN would otherwise silently produce unfiltered, title-less changelogs. /// public bool StrictFetch { get; init; } + + public bool IsNote { get; init; } + public string? NoteName { get; init; } } /// @@ -146,6 +149,72 @@ public async Task CreateChangelog(IDiagnosticsCollector collector, CreateC } } + public async Task CreateNote(IDiagnosticsCollector collector, CreateChangelogArguments input, Cancel ctx) + { + try + { + var cliDescription = input.Description; + input = EnrichFromCI(input); + + var config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx); + if (config == null) + { + collector.EmitError(string.Empty, "Failed to load changelog configuration"); + return false; + } + + input = ApplyConfigDefaults(input, config); + + // Mirror CreateChangelog: discard CI-injected description when extraction is disabled + if (input.ExtractionDisabled + && string.IsNullOrWhiteSpace(cliDescription) + && !string.IsNullOrWhiteSpace(input.Description)) + { + _logger.LogInformation("Clearing CI-provided description because release note extraction is disabled"); + input = input with { Description = null }; + } + + // Validate PR citation format (same rule as `add`: numeric refs require --owner/--repo) + if (input.Prs is { Length: > 1 }) + { + if (!_validator.ValidateMultiplePrFormat(collector, input.Prs, input.Owner, input.Repo)) + return false; + } + else if (!_validator.ValidatePrFormat(collector, input.Prs?.FirstOrDefault(), input.Owner, input.Repo)) + return false; + + // Validate issue citation format + if (input.Issues is { Length: > 1 }) + { + if (!_validator.ValidateMultipleIssueFormat(collector, input.Issues, input.Owner, input.Repo)) + return false; + } + else if (!_validator.ValidateIssueFormat(collector, input.Issues?.FirstOrDefault(), input.Owner, input.Repo)) + return false; + + if (!_validator.ValidateRequiredFields(collector, input, prFetchFailed: false)) + return false; + + if (!_validator.ValidateNoteProducts(collector, input)) + return false; + + if (!_validator.ValidateAgainstConfiguration(collector, input, config)) + return false; + + return await _fileWriter.WriteNoteAsync(input, config, ctx); + } + catch (IOException ioEx) + { + collector.EmitError(string.Empty, $"IO error creating note: {ioEx.Message}", ioEx); + return false; + } + catch (UnauthorizedAccessException uaEx) + { + collector.EmitError(string.Empty, $"Access denied creating note: {uaEx.Message}", uaEx); + return false; + } + } + internal static CreateChangelogArguments ApplyConfigDefaults(CreateChangelogArguments input, ChangelogConfiguration config) => // Filename strategy is always Pr now; UsePrNumber is kept for backward compat but is effectively always true. input with diff --git a/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs b/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs index c168fbee4..147085845 100644 --- a/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs +++ b/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs @@ -62,6 +62,59 @@ public async Task WriteChangelogAsync( return true; } + public async Task WriteNoteAsync( + CreateChangelogArguments input, + ChangelogConfiguration config, + Cancel ctx) + { + var changelogData = BuildChangelogData(input); + var yamlContent = input.Concise + ? GenerateConciseYaml(changelogData) + : GenerateYaml(changelogData, config, titleMissing: false, typeMissing: false); + + var outputDir = input.Output ?? fileSystem.Directory.GetCurrentDirectory(); + if (!fileSystem.Directory.Exists(outputDir)) + _ = fileSystem.Directory.CreateDirectory(outputDir); + + var filename = GenerateNoteFilename(input.NoteName, input.Title); + var filePath = fileSystem.Path.Join(outputDir, filename); + + var normalizedContent = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(yamlContent); + await fileSystem.File.WriteAllTextAsync(filePath, normalizedContent, Utf8NoBom, ctx); + logger.LogInformation("Created note fragment: {FilePath}", filePath); + return true; + } + + private static string GenerateNoteFilename(string? noteName, string? title) + { + var source = !string.IsNullOrWhiteSpace(noteName) ? noteName : title; + if (string.IsNullOrWhiteSpace(source)) + return "note-untitled.yml"; + var slug = Slugify(source); + return string.IsNullOrEmpty(slug) ? "note-untitled.yml" : $"note-{slug}.yml"; + } + + private static string Slugify(string text) + { + var sb = new StringBuilder(text.Length); + var prevWasHyphen = false; + foreach (var ch in text.ToLowerInvariant()) + { + if (char.IsLetterOrDigit(ch)) + { + _ = sb.Append(ch); + prevWasHyphen = false; + } + else if (!prevWasHyphen && sb.Length > 0) + { + _ = sb.Append('-'); + prevWasHyphen = true; + } + } + var result = sb.ToString().TrimEnd('-'); + return result.Length > 60 ? result[..60].TrimEnd('-') : result; + } + /// Maximum filename length before extension to avoid filesystem path-too-long errors. private const int MaxFilenameLength = 200; diff --git a/src/services/Elastic.Changelog/Creation/CreateChangelogArgumentsValidator.cs b/src/services/Elastic.Changelog/Creation/CreateChangelogArgumentsValidator.cs index 3a3eda3d6..d0e964691 100644 --- a/src/services/Elastic.Changelog/Creation/CreateChangelogArgumentsValidator.cs +++ b/src/services/Elastic.Changelog/Creation/CreateChangelogArgumentsValidator.cs @@ -31,14 +31,14 @@ public bool ValidatePrFormat(IDiagnosticsCollector collector, string? prUrl, str } /// - /// Validates that if all PRs are just numbers, owner and repo must be provided. + /// Validates that if any PR is a bare number, owner and repo must be provided. /// public bool ValidateMultiplePrFormat(IDiagnosticsCollector collector, string[] prs, string? owner, string? repo) { - var allAreNumbers = prs.All(pr => int.TryParse(pr.Trim(), out _)); - if (allAreNumbers && (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo))) + var anyAreNumbers = prs.Any(pr => int.TryParse(pr.Trim(), out _)); + if (anyAreNumbers && (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo))) { - collector.EmitError(string.Empty, "When --prs contains only numbers, both --owner and --repo must be provided"); + collector.EmitError(string.Empty, "When --prs contains any bare number, both --owner and --repo must be provided"); return false; } @@ -62,14 +62,14 @@ public bool ValidateIssueFormat(IDiagnosticsCollector collector, string? issueUr } /// - /// Validates that if all issues are just numbers, owner and repo must be provided. + /// Validates that if any issue is a bare number, owner and repo must be provided. /// public bool ValidateMultipleIssueFormat(IDiagnosticsCollector collector, string[] issues, string? owner, string? repo) { - var allAreNumbers = issues.All(i => int.TryParse(i.Trim(), out _)); - if (allAreNumbers && (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo))) + var anyAreNumbers = issues.Any(i => int.TryParse(i.Trim(), out _)); + if (anyAreNumbers && (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo))) { - collector.EmitError(string.Empty, "When --issues contains only numbers, both --owner and --repo must be provided"); + collector.EmitError(string.Empty, "When --issues contains any bare number, both --owner and --repo must be provided"); return false; } @@ -121,6 +121,21 @@ public bool ValidateRequiredFields( return true; } + public bool ValidateNoteProducts(IDiagnosticsCollector collector, CreateChangelogArguments input) + { + foreach (var product in input.Products) + { + if (string.IsNullOrWhiteSpace(product.Target) || product.Target == "*") + { + collector.EmitError(string.Empty, + $"Product '{product.Product}' must have a specific target for 'changelog note'. " + + "Use --products 'product target lifecycle' with a concrete target value (for example, '9.2.0' or '2026-05-15')."); + return false; + } + } + return true; + } + /// /// Validates input values against configuration. /// diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index f62f508fd..f7df5c68b 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -526,6 +526,178 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st return await serviceInvoker.InvokeAsync(ctx); } + /// Create a note-*.yml changelog fragment for items that have no associated PR. + /// + /// Use this command for release notes that are not tied to a specific pull request — for example, a + /// known-issue entry or a cross-cutting change that spans many PRs. Every product listed must have a + /// concrete target (not a wildcard); --prs and --issues are still accepted as + /// optional citations but do not affect the output filename. + /// + /// Explicit slug for the note filename. Defaults to a slug derived from the title. + /// Products in format "product target lifecycle, ..." (for example, "elasticsearch 9.2.0 ga"). Target is required for all products. + /// Optional action text. + /// Optional area tags. + /// Omit schema reference comments from the generated YAML. + /// Path to the changelog.yml configuration file. + /// Entry description. + /// Skip extracting release note text from PR/issue descriptions. + /// Skip extracting linked issues/PRs from PR/issue body. + /// Optional feature ID. + /// Mark the entry as a highlight. + /// Optional impact text. + /// Optional issue URLs (cited but not used as anchor). + /// GitHub owner. Falls back to bundle.owner or "elastic". + /// Output directory. + /// Optional PR URLs (cited but not used as anchor). + /// GitHub repository name. + /// Strip a repo-name prefix from the title. + /// Treat GitHub fetch failures as errors. + /// Entry subtype. + /// Entry title (required). + /// Entry type (required). + /// + [NoOptionsInjection] + public async Task Note( + string? name = null, + [ArgumentParser(typeof(ProductInfoParser))] ProductArgumentList? products = null, + string? action = null, + string[]? areas = null, + bool concise = false, + [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo? config = null, + string? description = null, + bool noExtractReleaseNotes = false, + bool noExtractIssues = false, + string? featureId = null, + bool? highlight = null, + string? impact = null, + string[]? issues = null, + string? owner = null, + string? output = null, + string[]? prs = null, + string? repo = null, + bool stripTitlePrefix = false, + bool strictFetch = false, + string? subtype = null, + string? title = null, + string? type = null, + CancellationToken ct = default + ) + { + var ctx = ct; + await using var serviceInvoker = new ServiceInvoker(collector); + + var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem) + .LoadChangelogConfiguration(collector, config?.FullName, ctx); + var resolvedRepo = !string.IsNullOrWhiteSpace(repo) ? repo : bundleConfig?.Bundle?.Repo; + var resolvedOwner = owner ?? bundleConfig?.Bundle?.Owner ?? "elastic"; + var resolvedOutput = !string.IsNullOrWhiteSpace(output) ? output : bundleConfig?.Bundle?.Directory; + var stripTitlePrefixResolved = stripTitlePrefix ? true : (bool?)null; + var extractReleaseNotes = noExtractReleaseNotes ? false : (bool?)null; + var extractIssues = noExtractIssues ? false : (bool?)null; + + string[]? parsedPrs = null; + if (prs is { Length: > 0 }) + { + var allPrs = new List(); + foreach (var trimmedValue in prs.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p.Trim())) + { + var normalizedPath = NormalizePath(trimmedValue); + if (_fileSystem.File.Exists(normalizedPath)) + { + try + { + var fileLines = await _fileSystem.File.ReadAllLinesAsync(normalizedPath, ctx); + foreach (var line in fileLines) + { + if (!string.IsNullOrWhiteSpace(line)) + allPrs.Add(line.Trim()); + } + } + catch (Exception ex) when (ex is IOException or SecurityException) + { + collector.EmitError(string.Empty, $"Failed to read PRs from file '{normalizedPath}': {ex.Message}", ex); + return 1; + } + } + else + { + allPrs.AddRange(trimmedValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } + } + parsedPrs = allPrs.ToArray(); + } + + string[]? parsedIssues = null; + if (issues is { Length: > 0 }) + { + var allIssues = new List(); + foreach (var trimmedValue in issues.Where(i => !string.IsNullOrWhiteSpace(i)).Select(i => i.Trim())) + { + var normalizedPath = NormalizePath(trimmedValue); + if (_fileSystem.File.Exists(normalizedPath)) + { + try + { + var fileLines = await _fileSystem.File.ReadAllLinesAsync(normalizedPath, ctx); + foreach (var line in fileLines) + { + if (!string.IsNullOrWhiteSpace(line)) + allIssues.Add(line.Trim()); + } + } + catch (Exception ex) when (ex is IOException or SecurityException) + { + collector.EmitError(string.Empty, $"Failed to read issues from file '{normalizedPath}': {ex.Message}", ex); + return 1; + } + } + else + { + allIssues.AddRange(trimmedValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } + } + parsedIssues = allIssues.ToArray(); + } + + var resolvedProducts = (IReadOnlyList?)products ?? []; + + IGitHubPrService githubPrService = new GitHubPrService(logFactory); + var service = new ChangelogCreationService(logFactory, configurationContext, _fileSystem, githubPrService, env: SystemEnvironmentVariables.Instance); + + var input = new CreateChangelogArguments + { + Title = title, + Type = type, + Products = resolvedProducts, + Subtype = subtype, + Areas = areas ?? [], + Prs = parsedPrs, + Owner = resolvedOwner, + Repo = resolvedRepo, + Issues = parsedIssues ?? [], + Description = description, + Impact = impact, + Action = action, + FeatureId = featureId, + Highlight = highlight, + Output = resolvedOutput, + Config = config?.FullName, + StripTitlePrefix = stripTitlePrefixResolved, + ExtractReleaseNotes = extractReleaseNotes, + ExtractIssues = extractIssues, + Concise = concise, + StrictFetch = strictFetch, + IsNote = true, + NoteName = name + }; + + serviceInvoker.AddCommand(service, input, + async static (s, collector, state, ctx) => await s.CreateNote(collector, state, ctx) + ); + + return await serviceInvoker.InvokeAsync(ctx); + } + /// Aggregate changelog entries matching a filter into a single bundle YAML. /// /// Profile-based commands (bundle <profile> <version|report> [report] [--plan]): filters, paths, repo metadata, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/NoteCreationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/NoteCreationTests.cs new file mode 100644 index 000000000..a85d81c2e --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/NoteCreationTests.cs @@ -0,0 +1,300 @@ +// 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 AwesomeAssertions; +using Elastic.Changelog.Creation; +using Elastic.Documentation; +using Elastic.Documentation.Diagnostics; +using FakeItEasy; + +namespace Elastic.Changelog.Tests.Changelogs.Create; + +public class NoteCreationTests(ITestOutputHelper output) : CreateChangelogTestBase(output) +{ + [Fact] + public async Task CreateNote_WithAllRequiredFields_WritesNoteFile() + { + var service = CreateService(); + var outputDir = CreateOutputDirectory(); + + var input = new CreateChangelogArguments + { + Title = "Slow rollover fix", + Type = "bug-fix", + Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Output = outputDir, + IsNote = true + }; + + var result = await service.CreateNote(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var files = FileSystem.Directory.GetFiles(outputDir, "*.yml"); + files.Should().HaveCount(1); + FileSystem.Path.GetFileName(files[0]).Should().StartWith("note-"); + var content = await FileSystem.File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + content.Should().Contain("Slow rollover fix"); + content.Should().Contain("bug-fix"); + } + + [Fact] + public async Task CreateNote_ProductWithoutTarget_ReturnsError() + { + var service = CreateService(); + + var input = new CreateChangelogArguments + { + Title = "Known issue", + Type = "known-issue", + Products = [new ProductArgument { Product = "elasticsearch", Target = "*", Lifecycle = "ga" }], + Output = CreateOutputDirectory(), + IsNote = true + }; + + var result = await service.CreateNote(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("elasticsearch") && d.Message.Contains("target")); + } + + [Fact] + public async Task CreateNote_EmptyTarget_ReturnsError() + { + var service = CreateService(); + + var input = new CreateChangelogArguments + { + Title = "Known issue", + Type = "known-issue", + Products = [new ProductArgument { Product = "elasticsearch", Target = "", Lifecycle = "ga" }], + Output = CreateOutputDirectory(), + IsNote = true + }; + + var result = await service.CreateNote(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("elasticsearch") && d.Message.Contains("target")); + } + + [Fact] + public async Task CreateNote_NameOverridesSlug_UsesProvidedName() + { + var service = CreateService(); + var outputDir = CreateOutputDirectory(); + + var input = new CreateChangelogArguments + { + Title = "Some very long title that would produce a different slug", + Type = "known-issue", + Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Output = outputDir, + IsNote = true, + NoteName = "tsdb-gap" + }; + + var result = await service.CreateNote(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + var files = FileSystem.Directory.GetFiles(outputDir, "*.yml"); + files.Should().HaveCount(1); + FileSystem.Path.GetFileName(files[0]).Should().Be("note-tsdb-gap.yml"); + } + + [Fact] + public async Task CreateNote_TitleSlugIsFilename_WhenNameAbsent() + { + var service = CreateService(); + var outputDir = CreateOutputDirectory(); + + var input = new CreateChangelogArguments + { + Title = "Fix slow rollover", + Type = "bug-fix", + Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Output = outputDir, + IsNote = true + }; + + var result = await service.CreateNote(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + var files = FileSystem.Directory.GetFiles(outputDir, "*.yml"); + files.Should().HaveCount(1); + FileSystem.Path.GetFileName(files[0]).Should().Be("note-fix-slow-rollover.yml"); + } + + [Fact] + public async Task CreateNote_NumericPrWithoutOwnerRepo_ReturnsError() + { + var service = CreateService(); + + var input = new CreateChangelogArguments + { + Title = "Known limitation", + Type = "known-issue", + Products = [new ProductArgument { Product = "elasticsearch", Target = "9.3.0", Lifecycle = "ga" }], + Prs = ["12345"], + Output = CreateOutputDirectory(), + IsNote = true + }; + + var result = await service.CreateNote(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("--owner") && d.Message.Contains("--repo")); + } + + [Fact] + public async Task CreateNote_NumericIssueWithoutOwnerRepo_ReturnsError() + { + var service = CreateService(); + + var input = new CreateChangelogArguments + { + Title = "Known limitation", + Type = "known-issue", + Products = [new ProductArgument { Product = "elasticsearch", Target = "9.3.0", Lifecycle = "ga" }], + Issues = ["456"], + Output = CreateOutputDirectory(), + IsNote = true + }; + + var result = await service.CreateNote(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("--owner") && d.Message.Contains("--repo")); + } + + [Fact] + public async Task CreateNote_WithPrs_AllowedWithoutError() + { + var service = CreateService(); + var outputDir = CreateOutputDirectory(); + + var input = new CreateChangelogArguments + { + Title = "Known limitation", + Type = "known-issue", + Products = [new ProductArgument { Product = "elasticsearch", Target = "9.3.0", Lifecycle = "ga" }], + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], + Output = outputDir, + IsNote = true, + NoteName = "known-limitation" + }; + + var result = await service.CreateNote(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + var files = FileSystem.Directory.GetFiles(outputDir, "*.yml"); + files.Should().HaveCount(1); + FileSystem.Path.GetFileName(files[0]).Should().Be("note-known-limitation.yml"); + var content = await FileSystem.File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + content.Should().Contain("pull/12345"); + } + + [Fact] + public async Task CreateNote_MixedNumericAndUrlPrWithoutOwnerRepo_ReturnsError() + { + var service = CreateService(); + + var input = new CreateChangelogArguments + { + Title = "Known limitation", + Type = "known-issue", + Products = [new ProductArgument { Product = "elasticsearch", Target = "9.3.0", Lifecycle = "ga" }], + Prs = ["12345", "https://github.com/elastic/elasticsearch/pull/999"], + Output = CreateOutputDirectory(), + IsNote = true + }; + + var result = await service.CreateNote(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("--owner") && d.Message.Contains("--repo")); + } + + [Fact] + public async Task CreateNote_MixedNumericAndUrlIssueWithoutOwnerRepo_ReturnsError() + { + var service = CreateService(); + + var input = new CreateChangelogArguments + { + Title = "Known limitation", + Type = "known-issue", + Products = [new ProductArgument { Product = "elasticsearch", Target = "9.3.0", Lifecycle = "ga" }], + Issues = ["456", "https://github.com/elastic/elasticsearch/issues/789"], + Output = CreateOutputDirectory(), + IsNote = true + }; + + var result = await service.CreateNote(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("--owner") && d.Message.Contains("--repo")); + } + + [Fact] + public async Task CreateNote_InCI_ExtractionDisabledByCli_ClearsCIDescription() + { + // language=yaml + var configContent = + """ + pivot: + types: + feature: "type:feature" + bug-fix: "type:bug-fix" + breaking-change: "type:breaking-change" + known-issue: + lifecycles: + - preview + - beta + - ga + """; + var configPath = await CreateConfigDirectory(configContent); + + var env = A.Fake(); + A.CallTo(() => env.IsRunningOnCI).Returns(true); + A.CallTo(() => env.GetEnvironmentVariable("CHANGELOG_PR_NUMBER")).Returns(null); + A.CallTo(() => env.GetEnvironmentVariable("CHANGELOG_TITLE")).Returns(null); + A.CallTo(() => env.GetEnvironmentVariable("CHANGELOG_DESCRIPTION")).Returns("CI injected description that should be suppressed"); + A.CallTo(() => env.GetEnvironmentVariable("CHANGELOG_TYPE")).Returns(null); + A.CallTo(() => env.GetEnvironmentVariable("CHANGELOG_OWNER")).Returns(null); + A.CallTo(() => env.GetEnvironmentVariable("CHANGELOG_REPO")).Returns(null); + A.CallTo(() => env.GetEnvironmentVariable("CHANGELOG_PRODUCTS")).Returns(null); + + var service = CreateService(env); + var outputDir = CreateOutputDirectory(); + + var input = new CreateChangelogArguments + { + Title = "Known memory leak", + Type = "known-issue", + Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Config = configPath, + Output = outputDir, + IsNote = true, + ExtractReleaseNotes = false + }; + + var result = await service.CreateNote(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + var files = FileSystem.Directory.GetFiles(outputDir, "*.yml"); + files.Should().HaveCount(1); + var content = await FileSystem.File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + content.Should().NotContain("CI injected description that should be suppressed"); + } +}