diff --git a/docs/cli/changelog/cmd-bundle-amend.md b/docs/cli/changelog/cmd-bundle-amend.md index cb586c7e9..4bea90979 100644 --- a/docs/cli/changelog/cmd-bundle-amend.md +++ b/docs/cli/changelog/cmd-bundle-amend.md @@ -63,11 +63,32 @@ Amend bundles created by older docs-builder versions may omit `products`; they a `--add` and `--remove` follow the same entry-sourcing gate as [](/cli/changelog/bundle.md): CDN by default when `bundle.repo` or the parent bundle's `repo` resolves; local disk when `--force-local` or `bundle.use_local_changelogs` is set, or when no authoring repo can be resolved. In CDN mode, paths are matched by file name (including CDN paths such as `/changelog/elastic/kibana/main/247279.yaml`) and do not need to exist locally. Use `--force-local` to read local changelogs from disk. -The parent bundle argument is always a local file. The command writes `{parent}.amend-N.yaml` next to it and does not fetch the parent from the CDN. +The parent may be a local bundle file or a published CDN locator (`/bundle/{product}/{file}.yaml`, leading slash optional). A local parent writes `{parent}.amend-N.yaml` next to that file. A CDN parent fetches the published bundle and any existing `amend-N` sidecars, then writes only the new sidecar locally — it does not download-and-rewrite the parent, and it does not upload. `--output` (a directory, or the exact `{parent}.amend-N.yaml` name for the next unused N) selects the write location for a CDN parent; when omitted, the command uses `bundle.output_directory` from `changelog.yml`, then the current directory. `--output` is ignored for a local parent. ::: ## Examples +### Amend a published CDN bundle + +Pass a CDN locator as the parent. `--add` can be a CDN entry path (matched by file name) when entry sourcing uses the CDN: + +```sh +docs-builder changelog bundle-amend \ + /bundle/kibana/9.3.0.yaml \ + --add /changelog/elastic/kibana/main/138723.yaml \ + --output ./docs/releases +``` + +This writes `9.3.0.amend-1.yaml` (or the next unused N) under `./docs/releases`. Upload is a separate step; the sidecar is uploaded like any other bundle YAML: + +```sh +docs-builder changelog upload \ + --artifact-type bundle \ + --directory ./docs/releases \ + --target s3 \ + --s3-bucket-name my-changelog-bundles +``` + ### Add a changelog from the CDN The first argument is the local parent bundle. `--add` can be a CDN path (matched by file name) when entry sourcing uses the CDN: diff --git a/docs/cli/changelog/cmd-upload.md b/docs/cli/changelog/cmd-upload.md index 2fb23b63b..a7ab915eb 100644 --- a/docs/cli/changelog/cmd-upload.md +++ b/docs/cli/changelog/cmd-upload.md @@ -80,7 +80,7 @@ Use `--artifact-type` to choose what to upload: Keying differs by artifact type: - **Changelog entries** are uploaded **once** under the authoring owner/repo/branch, regardless of how many products they list (or none). The owner is resolved from `--owner`, then `bundle.owner` in `changelog.yml`, then the git remote origin; the repo from `--repo`, then `bundle.repo`, then the git remote origin; the branch from `--branch`, then the current checkout's branch. The branch is stored verbatim, so a branch name containing `/` (for example `feature/foo`) becomes additional key segments. -- **Bundles** are uploaded once per product listed in the bundle's `products[].product` field (a bundle that declares multiple products is written under each product prefix). +- **Bundles** are uploaded once per product listed in the bundle's `products[].product` field (a bundle that declares multiple products is written under each product prefix). Amend sidecars produced from a CDN parent (`changelog bundle-amend /bundle/{product}/{file}.yaml`) are uploaded like any other bundle YAML. ## Upload targets diff --git a/docs/data/release-notes/bundle.md b/docs/data/release-notes/bundle.md index 3932e5a25..2b42aa3ba 100644 --- a/docs/data/release-notes/bundle.md +++ b/docs/data/release-notes/bundle.md @@ -286,7 +286,18 @@ To apply additional filtering by the changelog type, areas, or products, add [bu ## Amend bundles [changelog-bundle-amend] When you need to add changelogs to an existing bundle, you can use the `docs-builder changelog bundle-amend` command, which creates _amend bundles_. -The parent bundle path must be a local file (the amend sidecar is written next to it). `--add` and `--remove` accept the same CDN paths as `changelog bundle --files` when the authoring repo resolves. For example: +The parent may be a local bundle file (the sidecar is written next to it) or a published CDN locator such as `/bundle/kibana/9.3.0.yaml`. `--add` and `--remove` accept the same CDN entry paths as `changelog bundle --files` when the authoring repo resolves. For example: + +```sh +docs-builder changelog bundle-amend \ + /bundle/kibana/9.3.0.yaml \ + --add /changelog/elastic/kibana/main/138723.yaml \ + --output ./docs/releases +``` + +That writes `9.3.0.amend-1.yaml` under `./docs/releases`. Upload the sidecar with [](/cli/changelog/upload.md) (`--artifact-type bundle`); the command does not upload it for you. + +To read local changelog files from disk instead of the CDN, pass `--force-local`. A local parent file still works as before: ```sh docs-builder changelog bundle-amend \ @@ -294,8 +305,6 @@ docs-builder changelog bundle-amend \ --add /changelog/elastic/kibana/main/138723.yaml ``` -To read local changelog files from disk instead of the CDN, pass `--force-local`. - Amend bundles follow a specific naming convention: `{parent-bundle-name}.amend-{N}.yaml` where `{N}` is a sequence number. To remove entries from an existing bundle without editing the parent file, use `--remove` on the same command: diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogFetcher.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogFetcher.cs index 08cda2c97..ad535a5d7 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogFetcher.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogFetcher.cs @@ -11,6 +11,15 @@ namespace Elastic.Documentation.Configuration.ReleaseNotes; +/// +/// One named parent bundle plus its amend sidecars, fetched from the product bundle tree +/// (bundle/{product}/) without downloading the rest of the catalog. +/// +public readonly record struct CdnNamedBundle( + string FileName, + string Content, + IReadOnlyList AmendSidecars); + /// /// Fetches changelog bundles for a single product from the public CDN. It reads /// {base}/bundle/{product}/registry.json to enumerate bundles, downloads each @@ -143,6 +152,95 @@ public async Task> FetchAsync( return _bundleLoader.LoadBundlesFromContent(contents, emitWarning); } + /// + /// Fetches a single parent bundle and its listed {name}.amend-N.yaml sidecars from the + /// product tree. Reads bundle/{product}/registry.json (the scrubber-maintained bundle + /// index, not the changelog-entry pool) so sibling amends can be enumerated without downloading + /// the rest of the catalog. Returns null after emitting an error when the registry cannot + /// be read, the file is not listed, or a listed parent/amend cannot be fetched. + /// + public async Task FetchNamedBundleAsync( + Uri baseUri, + string product, + string fileName, + Action emitError, + Cancel ctx) + { + if (!ChangelogKeys.IsValidProduct(product)) + { + emitError($"Invalid changelog product '{product}': must be non-empty ASCII letters, digits, '_' or '-'."); + return null; + } + + if (!ChangelogKeys.IsSafeFileName(fileName)) + { + emitError($"Invalid changelog bundle file name '{fileName}'."); + return null; + } + + var registryUri = Combine(baseUri, [.. ChangelogKeys.BundleSegments(product), ChangelogKeys.RegistryFileName]); + + ChangelogRegistry? registry; + try + { + registry = await FetchRegistryAsync(registryUri, ctx).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + emitError($"Could not fetch changelog registry for product '{product}' from {registryUri}: {ex.Message}"); + return null; + } + + if (registry is null) + { + emitError($"Changelog registry for product '{product}' at {registryUri} was empty or unparseable."); + return null; + } + + if (registry.SchemaVersion > SupportedSchemaVersion) + { + emitError( + $"Changelog registry for product '{product}' uses schema version {registry.SchemaVersion}, but this build only understands version {SupportedSchemaVersion}. Update docs-builder."); + return null; + } + + var listed = registry.Bundles + .Where(b => ChangelogKeys.IsSafeFileName(b.File)) + .ToList(); + + var parentEntry = listed.Find(b => string.Equals(b.File, fileName, StringComparison.OrdinalIgnoreCase)); + if (parentEntry?.File is null) + { + emitError($"Bundle '{fileName}' is not listed in the changelog registry for product '{product}'."); + return null; + } + + var parent = await DownloadOrCacheBundleAsync(baseUri, product, parentEntry.File, parentEntry.ETag, emitError, ctx) + .ConfigureAwait(false); + if (parent is null) + return null; + + var amendEntries = listed + .Where(b => b.File is not null + && BundleAmendMerger.IsAmendFile(b.File) + && string.Equals(BundleAmendMerger.GetParentBundlePath(b.File), parent.Value.FileName, StringComparison.OrdinalIgnoreCase)) + .OrderBy(b => BundleAmendMerger.GetAmendFileNumber(b.File!)) + .ToList(); + + var amends = new List(amendEntries.Count); + foreach (var amend in amendEntries) + { + ctx.ThrowIfCancellationRequested(); + var fetched = await DownloadOrCacheBundleAsync(baseUri, product, amend.File!, amend.ETag, emitError, ctx) + .ConfigureAwait(false); + if (fetched is null) + return null; + amends.Add(new CdnChangelogEntry(fetched.Value.FileName, fetched.Value.Content)); + } + + return new CdnNamedBundle(parent.Value.FileName, parent.Value.Content, amends); + } + private async Task FetchRegistryAsync(Uri registryUri, Cancel ctx) { _logger.LogInformation("Fetching changelog registry {RegistryUri}", registryUri); diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogKeys.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogKeys.cs index dbbe3e22d..2fbfbb418 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogKeys.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogKeys.cs @@ -98,6 +98,58 @@ public static string BundleRegistryKey(string productGroup) => public static string ChangelogRegistryKey(string poolGroup) => $"{ChangelogPrefix}{poolGroup}/{RegistryFileName}"; + /// + /// Parses a CDN bundle locator into product and file name. Accepts + /// /bundle/{product}/{file} (leading slash optional) or an absolute http(s) URL whose path + /// contains that layout. Returns false for changelog-pool paths. Amend sidecars still parse as + /// locators; callers that need a parent should reject them with BundleAmendMerger.IsAmendFile. + /// + public static bool TryParseBundleLocator( + string? input, + [NotNullWhen(true)] out string? product, + [NotNullWhen(true)] out string? fileName) + { + product = null; + fileName = null; + if (string.IsNullOrWhiteSpace(input)) + return false; + + var trimmed = input.Trim(); + string key; + if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https") + { + var path = uri.AbsolutePath.TrimStart('/'); + var prefixAt = path.IndexOf(BundlePrefix, StringComparison.Ordinal); + if (prefixAt < 0) + return false; + key = path[prefixAt..]; + } + else + { + key = trimmed.TrimStart('/'); + if (!key.StartsWith(BundlePrefix, StringComparison.Ordinal)) + return false; + } + + product = ExtractBundleGroup(key); + if (product is null) + return false; + + var fileStart = BundlePrefix.Length + product.Length + 1; + if (key.Length <= fileStart) + return false; + + fileName = key[fileStart..]; + if (!IsSafeFileName(fileName)) + { + product = null; + fileName = null; + return false; + } + + return true; + } + /// /// Extracts the product group from a bundle/{product}/{file} key, or null when /// is not a bundle key with a valid product segment ahead of the file name. diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs index 73b9dfc95..bf659d25f 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs @@ -2,10 +2,8 @@ // 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.Globalization; using System.IO.Abstractions; using System.Text; -using System.Text.RegularExpressions; using Elastic.Changelog.Utilities; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; @@ -26,7 +24,8 @@ namespace Elastic.Changelog.Bundling; public record AmendBundleArguments { /// - /// Path to the original bundle file to amend + /// Path to the parent bundle: a local file, or a CDN locator + /// /bundle/{product}/{file}.yaml. /// public required string BundlePath { get; init; } @@ -54,16 +53,23 @@ public record AmendBundleArguments /// Preview changes without writing an amend file. /// public bool DryRun { get; init; } + + /// + /// Directory or {parent}.amend-N.yaml path for the new sidecar. Used only when the parent + /// is a CDN bundle locator; local parents always write beside the parent file. + /// + public string? Output { get; init; } } /// /// Service for amending changelog bundles with additional entries /// -public partial class ChangelogBundleAmendService( +public class ChangelogBundleAmendService( ILoggerFactory logFactory, IChangelogFileSystem fileSystem, IConfigurationContext? configurationContext = null, - CdnChangelogEntryFetcher? entryFetcher = null) : IService + CdnChangelogEntryFetcher? entryFetcher = null, + CdnChangelogFetcher? bundleFetcher = null) : IService { /// /// UTF-8 encoding without BOM for writing YAML files. @@ -73,13 +79,11 @@ public partial class ChangelogBundleAmendService( private readonly ILogger _logger = logFactory.CreateLogger(); private readonly IChangelogFileSystem _fileSystem = fileSystem; private readonly CdnChangelogEntryFetcher _entryFetcher = entryFetcher ?? new CdnChangelogEntryFetcher(logFactory); + private readonly CdnChangelogFetcher _bundleFetcher = bundleFetcher ?? new CdnChangelogFetcher(logFactory, fileSystem); private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem) : null; - [GeneratedRegex(@"\.amend-(\d+)\.ya?ml$", RegexOptions.IgnoreCase)] - private static partial Regex AmendFileRegex(); - /// /// Amends a bundle with additional or excluded changelog entries, creating a new immutable amend file. /// @@ -87,27 +91,14 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle { try { - if (!_fileSystem.File.Exists(input.BundlePath)) - { - var currentDir = _fileSystem.Directory.GetCurrentDirectory(); - collector.EmitError( - input.BundlePath, - $"Bundle file does not exist. Current directory: {currentDir}" - ); - return false; - } - if (input.AddFiles.Count == 0 && input.RemoveFiles.Count == 0) { collector.EmitError(string.Empty, "At least one file must be specified with --add or --remove"); return false; } - var (parentOk, parentBundle) = await TryDeserializeParentBundleAsync( - input.BundlePath, - collector, - ctx); - if (!parentOk || parentBundle == null) + var parent = await ResolveParentAsync(input.BundlePath, collector, ctx); + if (parent is null) return false; ChangelogConfiguration? changelogConfig = null; @@ -118,6 +109,16 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle return false; } + var writeDirectory = ResolveWriteDirectory(parent, input, changelogConfig, collector, out var requestedAmendFileName); + var (amendsOk, existingAmendBundles, nextAmendNumber) = await LoadExistingAmendBundlesAsync( + parent, + writeDirectory, + collector, + ctx); + if (!amendsOk) + return false; + + var parentBundle = parent.Bundle; var useLocalChangelogs = (changelogConfig?.Bundle?.UseLocalChangelogs ?? false) || input.ForceLocal; var authoringRepo = ChangelogRepoOwnerResolver.NormalizeRepo( changelogConfig?.Bundle?.Repo ?? (parentBundle.Products.Count > 0 ? parentBundle.Products[0].Repo : null)); @@ -162,13 +163,6 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle if (removeSources is null) return false; - var (amendsOk, existingAmendBundles) = await LoadExistingAmendBundlesAsync( - input.BundlePath, - collector, - ctx); - if (!amendsOk) - return false; - var effectiveEntries = BundleAmendMerger.MergeEntries(parentBundle.Entries, existingAmendBundles); var appliedExclusionKeys = BundleAmendMerger.CollectAppliedExclusionKeys(existingAmendBundles); @@ -237,18 +231,28 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle return true; } + var amendFileName = $"{parent.BaseName}.amend-{nextAmendNumber}{parent.Extension}"; + if (!string.IsNullOrWhiteSpace(requestedAmendFileName) + && !string.Equals(requestedAmendFileName, amendFileName, StringComparison.OrdinalIgnoreCase)) + { + collector.EmitError( + input.Output ?? string.Empty, + $"--output file name '{requestedAmendFileName}' must be '{amendFileName}' (next amend number is {nextAmendNumber})."); + return false; + } + + var amendFilePath = _fileSystem.Path.Join(writeDirectory, amendFileName); + if (input.DryRun) { _logger.LogInformation( - "Dry run: would exclude {ExcludeCount} and add {AddCount} entries", + "Dry run: would exclude {ExcludeCount} and add {AddCount} entries at {AmendFilePath}", excludeEntries.Count, - entries.Count); + entries.Count, + amendFilePath); return true; } - var nextAmendNumber = GetNextAmendNumber(input.BundlePath); - var amendFilePath = GenerateAmendFilePath(input.BundlePath, nextAmendNumber); - _logger.LogInformation( "Creating amend file: {AmendFilePath} (exclude={ExcludeCount}, add={AddCount})", amendFilePath, @@ -308,7 +312,7 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle var normalizedYaml = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(yaml); await _fileSystem.File.WriteAllTextAsync(amendFilePath, normalizedYaml, Utf8NoBom, ctx); _logger.LogInformation( - "Created amend file: {AmendFilePath} with {ExcludeCount} exclusions and {AddCount} additions", + "Created amend file: {AmendFilePath} with {ExcludeCount} exclusions and {AddCount} additions. Upload with: changelog upload --artifact-type bundle", amendFilePath, excludeEntries.Count, entries.Count); @@ -444,30 +448,235 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle return sourced; } - private async Task<(bool Ok, List Bundles)> LoadExistingAmendBundlesAsync( + private async Task ResolveParentAsync( + string bundlePath, + IDiagnosticsCollector collector, + Cancel ctx) + { + if (_fileSystem.File.Exists(bundlePath)) + return await ResolveLocalParentAsync(bundlePath, collector, ctx).ConfigureAwait(false); + + if (ChangelogKeys.TryParseBundleLocator(bundlePath, out var product, out var fileName)) + return await ResolveCdnParentAsync(bundlePath, product, fileName, collector, ctx).ConfigureAwait(false); + + var currentDir = _fileSystem.Directory.GetCurrentDirectory(); + collector.EmitError( + bundlePath, + $"Bundle file does not exist. Current directory: {currentDir}. " + + "A CDN parent must look like /bundle/{product}/{file}.yaml (or an http(s) URL with that path)."); + return null; + } + + private async Task ResolveLocalParentAsync( + string bundlePath, + IDiagnosticsCollector collector, + Cancel ctx) + { + if (BundleAmendMerger.IsAmendFile(bundlePath)) + { + collector.EmitError(bundlePath, "The bundle path is an amend sidecar; pass the parent bundle instead."); + return null; + } + + if (!HasYamlExtension(bundlePath)) + { + collector.EmitError(bundlePath, "The parent bundle must be a .yaml or .yml file."); + return null; + } + + var (ok, bundle) = await TryDeserializeParentBundleAsync(bundlePath, collector, ctx).ConfigureAwait(false); + if (!ok || bundle is null) + return null; + + var fileName = _fileSystem.Path.GetFileName(bundlePath); + return new ResolvedParent + { + Bundle = bundle, + FileName = fileName, + BaseName = _fileSystem.Path.GetFileNameWithoutExtension(fileName), + Extension = _fileSystem.Path.GetExtension(fileName), + IsCdn = false, + LocalPath = bundlePath + }; + } + + private async Task ResolveCdnParentAsync( string bundlePath, + string product, + string fileName, IDiagnosticsCollector collector, Cancel ctx) { - var amendPaths = DiscoverAmendFiles(_fileSystem, bundlePath); - var amendBundles = new List(); - foreach (var amendPath in amendPaths) + if (BundleAmendMerger.IsAmendFile(fileName)) + { + collector.EmitError(bundlePath, "The bundle path is an amend sidecar; pass the parent bundle instead."); + return null; + } + + if (!HasYamlExtension(fileName)) + { + collector.EmitError(bundlePath, "A CDN parent must be a .yaml or .yml bundle file under /bundle/{product}/."); + return null; + } + + var baseUri = ChangelogCdn.ResolveBaseUri(); + if (baseUri is null) + { + collector.EmitError(string.Empty, + $"No valid changelog CDN base URL is configured. Set the {ChangelogCdn.BaseUrlEnvironmentVariable} environment variable to an absolute http(s) URL."); + return null; + } + + var fatal = false; + var fetched = await _bundleFetcher.FetchNamedBundleAsync( + baseUri, + product, + fileName, + msg => { fatal = true; collector.EmitError(bundlePath, msg); }, + ctx).ConfigureAwait(false); + + if (fatal || fetched is null) + return null; + + try + { + var bundle = ReleaseNotesSerialization.DeserializeBundle(fetched.Value.Content); + return new ResolvedParent + { + Bundle = bundle, + FileName = fetched.Value.FileName, + BaseName = _fileSystem.Path.GetFileNameWithoutExtension(fetched.Value.FileName), + Extension = _fileSystem.Path.GetExtension(fetched.Value.FileName), + IsCdn = true, + CdnAmends = [.. fetched.Value.AmendSidecars.Select(a => (a.FileName, a.Content))] + }; + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException)) + { + collector.EmitError(bundlePath, $"Failed to parse parent bundle YAML: {ex.Message}", ex); + return null; + } + } + + private string ResolveWriteDirectory( + ResolvedParent parent, + AmendBundleArguments input, + ChangelogConfiguration? changelogConfig, + IDiagnosticsCollector collector, + out string? requestedAmendFileName) + { + requestedAmendFileName = null; + if (!parent.IsCdn) + { + if (!string.IsNullOrWhiteSpace(input.Output)) + { + collector.EmitWarning( + input.Output, + "--output is ignored for a local parent bundle; the amend file is written next to the parent."); + } + + return _fileSystem.Path.GetDirectoryName(parent.LocalPath) ?? string.Empty; + } + + if (!string.IsNullOrWhiteSpace(input.Output)) + { + if (HasYamlExtension(input.Output)) + { + requestedAmendFileName = _fileSystem.Path.GetFileName(input.Output); + var directory = _fileSystem.Path.GetDirectoryName(input.Output); + return string.IsNullOrWhiteSpace(directory) + ? _fileSystem.Directory.GetCurrentDirectory() + : directory; + } + + return input.Output; + } + + if (!string.IsNullOrWhiteSpace(changelogConfig?.Bundle?.OutputDirectory)) + return changelogConfig.Bundle.OutputDirectory; + + return _fileSystem.Directory.GetCurrentDirectory(); + } + + private async Task<(bool Ok, List Bundles, int NextAmendNumber)> LoadExistingAmendBundlesAsync( + ResolvedParent parent, + string writeDirectory, + IDiagnosticsCollector collector, + Cancel ctx) + { + var byFileName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (fileName, content) in parent.CdnAmends) + { + if (!TryDeserializeAmend(fileName, content, collector, out var bundle) || bundle is null) + return (false, [], 0); + byFileName[fileName] = bundle; + } + + var siblingParentPath = parent.IsCdn + ? _fileSystem.Path.Join(writeDirectory, parent.FileName) + : parent.LocalPath!; + foreach (var amendPath in DiscoverAmendFiles(_fileSystem, siblingParentPath)) { try { var content = await _fileSystem.File.ReadAllTextAsync(amendPath, ctx); - amendBundles.Add(ReleaseNotesSerialization.DeserializeBundle(content)); + var fileName = _fileSystem.Path.GetFileName(amendPath); + byFileName[fileName] = ReleaseNotesSerialization.DeserializeBundle(content); } catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or ThreadAbortException)) { - collector.EmitError( - amendPath, - $"Failed to deserialize amend file: {ex.Message}", - ex); - return (false, []); + collector.EmitError(amendPath, $"Failed to deserialize amend file: {ex.Message}", ex); + return (false, [], 0); } } - return (true, amendBundles); + + var orderedNames = byFileName.Keys + .OrderBy(BundleAmendMerger.GetAmendFileNumber) + .ToList(); + var bundles = orderedNames.Select(name => byFileName[name]).ToList(); + var nextNumber = orderedNames + .Select(BundleAmendMerger.GetAmendFileNumber) + .DefaultIfEmpty(0) + .Max() + 1; + return (true, bundles, nextNumber); + } + + private static bool TryDeserializeAmend( + string fileName, + string content, + IDiagnosticsCollector collector, + out Bundle? bundle) + { + try + { + bundle = ReleaseNotesSerialization.DeserializeBundle(content); + return true; + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or ThreadAbortException)) + { + collector.EmitError(fileName, $"Failed to deserialize amend file: {ex.Message}", ex); + bundle = null; + return false; + } + } + + private static bool HasYamlExtension(string path) + { + var extension = Path.GetExtension(path); + return extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".yml", StringComparison.OrdinalIgnoreCase); + } + + private sealed record ResolvedParent + { + public required Bundle Bundle { get; init; } + public required string FileName { get; init; } + public required string BaseName { get; init; } + public required string Extension { get; init; } + public required bool IsCdn { get; init; } + public string? LocalPath { get; init; } + public IReadOnlyList<(string FileName, string Content)> CdnAmends { get; init; } = []; } private RemoveExclusionResult? BuildExclusionEntry( @@ -597,32 +806,6 @@ private Skip() { } } } - private int GetNextAmendNumber(string bundlePath) - { - var directory = _fileSystem.Path.GetDirectoryName(bundlePath) ?? string.Empty; - var baseName = _fileSystem.Path.GetFileNameWithoutExtension(bundlePath); - - var existingAmendFiles = _fileSystem.Directory.GetFiles(directory, $"{baseName}.amend-*.y*ml"); - - var maxNumber = existingAmendFiles - .Select(file => AmendFileRegex().Match(file)) - .Where(match => match.Success && int.TryParse(match.Groups[1].Value, out _)) - .Select(match => int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture)) - .DefaultIfEmpty(0) - .Max(); - - return maxNumber + 1; - } - - private string GenerateAmendFilePath(string bundlePath, int amendNumber) - { - var directory = _fileSystem.Path.GetDirectoryName(bundlePath) ?? string.Empty; - var baseName = _fileSystem.Path.GetFileNameWithoutExtension(bundlePath); - var extension = _fileSystem.Path.GetExtension(bundlePath); - - return _fileSystem.Path.Join(directory, $"{baseName}.amend-{amendNumber}{extension}"); - } - private BundledEntry? LoadChangelogContent( IDiagnosticsCollector collector, SourcedChangelog source) diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index bed5c01ea..4dc9fedd9 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -24,6 +24,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; @@ -1339,21 +1340,28 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c } /// Append or exclude changelog entries in a published bundle without modifying it. - /// Creates an immutable .amend-N.yaml sidecar file alongside the original bundle. - /// Required: Path to the original bundle file to amend + /// + /// Creates an immutable .amend-N.yaml sidecar. The parent may be a local file (the sidecar + /// is written next to it) or a CDN locator /bundle/{product}/{file}.yaml (the sidecar is + /// written under --output, bundle.output_directory, or the current directory). + /// Upload the sidecar separately with changelog upload --artifact-type bundle. + /// + /// Required: Local path to the parent bundle, or a CDN locator /bundle/{product}/{file}.yaml (leading slash optional; an absolute http(s) URL with that path is also accepted). Local paths support tilde (~) expansion and must be a .yml/.yaml file that exists on disk. /// Optional: Changelog YAML paths to add. Repeat --add or pass a comma-separated list in one value (for example, --add "file1.yaml,file2.yaml"). Supports tilde (~) expansion and relative paths. When entries are sourced from the CDN (the default when bundle.repo or the parent bundle's repo resolves), paths are matched by file name and do not need to exist locally; with local sourcing (--force-local or bundle.use_local_changelogs) the paths must exist on disk. /// Optional: Changelog YAML paths to exclude from the effective bundle. Repeat --remove or pass a comma-separated list in one value. Supports tilde (~) expansion and relative paths. When entries are sourced from the CDN, paths are matched by file name and do not need to exist locally; with local sourcing the paths must exist on disk unless --force is used to exclude by file name. /// Optional: When removing, match by file name even if the bundle checksum differs from the sourced changelog, or when no YAML can be sourced (inferred git-ref entries). /// Optional: Force local entry sourcing for this run (equivalent to bundle.use_local_changelogs: true without editing config). /// Optional: Preview changes without writing an amend file. + /// Optional: Where to write the new sidecar when the parent is a CDN locator. A directory, or a .yaml/.yml path whose file name must be {parent}.amend-N.yaml for the next unused N. Falls back to bundle.output_directory in changelog.yml, then the current directory. Ignored for a local parent. [NoOptionsInjection] public async Task BundleAmend( - [Argument, Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo bundlePath, + [Argument] string bundlePath, string[]? add = null, string[]? remove = null, bool force = false, bool forceLocal = false, bool dryRun = false, + string? output = null, CancellationToken ct = default ) { @@ -1378,7 +1386,10 @@ public async Task BundleAmend( return 1; } - var normalizedBundlePath = bundlePath.FullName; + var trimmedBundlePath = bundlePath.Trim(); + var normalizedBundlePath = ChangelogKeys.TryParseBundleLocator(trimmedBundlePath, out _, out _) + ? trimmedBundlePath + : NormalizePath(trimmedBundlePath); var input = new AmendBundleArguments { @@ -1387,7 +1398,8 @@ public async Task BundleAmend( RemoveFiles = normalizedRemoveFiles, Force = force, ForceLocal = forceLocal, - DryRun = dryRun + DryRun = dryRun, + Output = string.IsNullOrWhiteSpace(output) ? null : NormalizePath(output) }; serviceInvoker.AddCommand(service, input, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendCdnParentTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendCdnParentTests.cs new file mode 100644 index 000000000..688955f4a --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendCdnParentTests.cs @@ -0,0 +1,319 @@ +// 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.Net; +using AwesomeAssertions; +using Elastic.Changelog.Bundling; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.ReleaseNotes; +using Elastic.Documentation.Diagnostics; + +namespace Elastic.Changelog.Tests.Changelogs; + +public class BundleAmendCdnParentTests(ITestOutputHelper output) : ChangelogTestBase(output) +{ + // language=yaml + private const string ExistingEntry = + """ + title: Existing feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + """; + + // language=yaml + private const string LateEntry = + """ + title: Late addition + type: enhancement + products: + - product: elasticsearch + target: 9.3.0 + """; + + [Fact] + public async Task Amend_CdnParent_WritesAmend1UnderOutput() + { + var outputDir = CreateDir(); + var handler = CombinedHandler(parentYaml: ParentBundleYaml("existing.yaml", ExistingEntry), lateYaml: LateEntry); + var service = Service(handler); + + var result = await service.AmendBundle(Collector, new AmendBundleArguments + { + BundlePath = "/bundle/elasticsearch/9.3.0.yaml", + AddFiles = ["/changelog/elastic/elasticsearch/main/late.yaml"], + Output = outputDir + }, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/registry.json"); + handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/9.3.0.yaml"); + handler.RequestedPaths.Should().NotContain(p => p.EndsWith("/9.4.0.yaml", StringComparison.Ordinal)); + var amendPath = FileSystem.Path.Join(outputDir, "9.3.0.amend-1.yaml"); + FileSystem.File.Exists(amendPath).Should().BeTrue(); + var amend = await FileSystem.File.ReadAllTextAsync(amendPath, TestContext.Current.CancellationToken); + amend.Should().Contain("title: Late addition"); + amend.Should().Contain("name: late.yaml"); + } + + [Fact] + public async Task Amend_CdnParent_ExistingCdnAmend_WritesAmend2() + { + var outputDir = CreateDir(); + var handler = CombinedHandler( + parentYaml: ParentBundleYaml("existing.yaml", ExistingEntry), + lateYaml: LateEntry, + cdnAmendYaml: AmendSidecarYaml("prior.yaml")); + var service = Service(handler); + + var result = await service.AmendBundle(Collector, new AmendBundleArguments + { + BundlePath = "/bundle/elasticsearch/9.3.0.yaml", + AddFiles = ["late.yaml"], + Output = outputDir + }, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/9.3.0.amend-1.yaml"); + FileSystem.File.Exists(FileSystem.Path.Join(outputDir, "9.3.0.amend-1.yaml")).Should().BeFalse(); + FileSystem.File.Exists(FileSystem.Path.Join(outputDir, "9.3.0.amend-2.yaml")).Should().BeTrue(); + } + + [Fact] + public async Task Amend_CdnParent_LocalSiblingAmend_WritesAmend2() + { + var outputDir = CreateDir(); + await FileSystem.File.WriteAllTextAsync( + FileSystem.Path.Join(outputDir, "9.3.0.amend-1.yaml"), + AmendSidecarYaml("local-prior.yaml"), + TestContext.Current.CancellationToken); + var handler = CombinedHandler(parentYaml: ParentBundleYaml("existing.yaml", ExistingEntry), lateYaml: LateEntry); + var service = Service(handler); + + var result = await service.AmendBundle(Collector, new AmendBundleArguments + { + BundlePath = "/bundle/elasticsearch/9.3.0.yaml", + AddFiles = ["late.yaml"], + Output = outputDir + }, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + FileSystem.File.Exists(FileSystem.Path.Join(outputDir, "9.3.0.amend-2.yaml")).Should().BeTrue(); + } + + [Fact] + public async Task Amend_LocalParent_StillWritesBesideParent_IgnoringOutput() + { + var bundlePath = await WriteLocalParentAsync(); + var outputDir = CreateDir(); + var localDir = CreateDir(); + var localFile = FileSystem.Path.Join(localDir, "late.yaml"); + await FileSystem.File.WriteAllTextAsync(localFile, LateEntry, TestContext.Current.CancellationToken); + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + var service = Service(handler); + + var result = await service.AmendBundle(Collector, new AmendBundleArguments + { + BundlePath = bundlePath, + AddFiles = [localFile], + ForceLocal = true, + Output = outputDir + }, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + handler.RequestedPaths.Should().BeEmpty("a local parent with --force-local must not reach the CDN"); + FileSystem.File.Exists(FileSystem.Path.Join(FileSystem.Path.GetDirectoryName(bundlePath), "bundle.amend-1.yaml")).Should().BeTrue(); + FileSystem.Directory.GetFiles(outputDir).Should().BeEmpty(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Warning && d.Message.Contains("--output is ignored")); + } + + [Fact] + public async Task Amend_LocalParent_MissingFile_FailsWithoutWriting() + { + var outputDir = CreateDir(); + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + var service = Service(handler); + + var result = await service.AmendBundle(Collector, new AmendBundleArguments + { + BundlePath = FileSystem.Path.Join(outputDir, "missing.yaml"), + AddFiles = ["late.yaml"], + Output = outputDir + }, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("does not exist")); + FileSystem.Directory.GetFiles(outputDir).Should().BeEmpty(); + } + + [Fact] + public async Task Amend_CdnParent_UnknownBundleFile_FailsWithoutWriting() + { + var outputDir = CreateDir(); + var handler = CombinedHandler(parentYaml: ParentBundleYaml("existing.yaml", ExistingEntry), lateYaml: LateEntry); + var service = Service(handler); + + var result = await service.AmendBundle(Collector, new AmendBundleArguments + { + BundlePath = "/bundle/elasticsearch/missing.yaml", + AddFiles = ["late.yaml"], + Output = outputDir + }, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("not listed")); + FileSystem.Directory.GetFiles(outputDir).Should().BeEmpty(); + } + + [Fact] + public async Task Amend_CdnParent_AmendSidecarAsParent_FailsWithoutWriting() + { + var outputDir = CreateDir(); + var handler = CombinedHandler(parentYaml: ParentBundleYaml("existing.yaml", ExistingEntry), lateYaml: LateEntry); + var service = Service(handler); + + var result = await service.AmendBundle(Collector, new AmendBundleArguments + { + BundlePath = "/bundle/elasticsearch/9.3.0.amend-1.yaml", + AddFiles = ["late.yaml"], + Output = outputDir + }, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("amend sidecar")); + handler.RequestedPaths.Should().BeEmpty(); + FileSystem.Directory.GetFiles(outputDir).Should().BeEmpty(); + } + + [Fact] + public async Task Amend_BadPathShape_FailsWithoutWriting() + { + var outputDir = CreateDir(); + var handler = CombinedHandler(parentYaml: ParentBundleYaml("existing.yaml", ExistingEntry), lateYaml: LateEntry); + var service = Service(handler); + + var result = await service.AmendBundle(Collector, new AmendBundleArguments + { + BundlePath = "/changelog/elastic/elasticsearch/main/9.3.0.yaml", + AddFiles = ["late.yaml"], + Output = outputDir + }, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("/bundle/{product}/{file}.yaml")); + handler.RequestedPaths.Should().BeEmpty(); + FileSystem.Directory.GetFiles(outputDir).Should().BeEmpty(); + } + + private ChangelogBundleAmendService Service(StubHandler handler) + { + var entryFetcher = new CdnChangelogEntryFetcher(LoggerFactory, handler, sleep: (_, _) => Task.CompletedTask); + var bundleFetcher = new CdnChangelogFetcher(LoggerFactory, FileSystem, handler); + return new ChangelogBundleAmendService( + LoggerFactory, + FileSystem, + entryFetcher: entryFetcher, + bundleFetcher: bundleFetcher); + } + + private string CreateDir() + { + var dir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(dir); + return dir; + } + + private async Task WriteLocalParentAsync() + { + var bundleDir = CreateDir(); + var bundlePath = FileSystem.Path.Join(bundleDir, "bundle.yaml"); + await FileSystem.File.WriteAllTextAsync( + bundlePath, + ParentBundleYaml("existing.yaml", ExistingEntry), + TestContext.Current.CancellationToken); + return bundlePath; + } + + private string ParentBundleYaml(string fileName, string changelogYaml) + { + var checksum = ComputeSha1(changelogYaml); + return $""" + products: + - product: elasticsearch + target: 9.3.0 + repo: elasticsearch + owner: elastic + entries: + - file: + name: {fileName} + checksum: {checksum} + type: feature + title: Existing feature + """; + } + + private static string AmendSidecarYaml(string entryName) => + $""" + products: + - product: elasticsearch + target: 9.3.0 + repo: elasticsearch + owner: elastic + entries: + - file: + name: {entryName} + checksum: placeholder + type: bug-fix + title: Prior amend + """; + + private static StubHandler CombinedHandler(string parentYaml, string lateYaml, string? cdnAmendYaml = null) + { + var bundleRegistry = cdnAmendYaml is null + ? /*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.4.0.yaml", "target": "9.4.0" }, { "file": "9.3.0.yaml", "target": "9.3.0" } ] }""" + : /*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.4.0.yaml", "target": "9.4.0" }, { "file": "9.3.0.yaml", "target": "9.3.0" }, { "file": "9.3.0.amend-1.yaml", "target": "9.3.0" } ] }"""; + var poolRegistry = /*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "late.yaml" } ] }"""; + return new StubHandler(req => + { + var path = req.RequestUri!.AbsolutePath; + if (path.Contains("/bundle/", StringComparison.Ordinal) && path.EndsWith("/registry.json", StringComparison.Ordinal)) + return Json(bundleRegistry); + if (path.Contains("/changelog/", StringComparison.Ordinal) && path.EndsWith("/registry.json", StringComparison.Ordinal)) + return Json(poolRegistry); + if (path.EndsWith("/9.3.0.yaml", StringComparison.Ordinal)) + return Yaml(parentYaml); + if (path.EndsWith("/9.3.0.amend-1.yaml", StringComparison.Ordinal) && cdnAmendYaml is not null) + return Yaml(cdnAmendYaml); + if (path.EndsWith("/late.yaml", StringComparison.Ordinal)) + return Yaml(lateYaml); + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + } + + private static HttpResponseMessage Json(string body) => + new(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") }; + + private static HttpResponseMessage Yaml(string body) => + new(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "text/yaml") }; + + private sealed class StubHandler(Func responder) : HttpMessageHandler + { + public List RequestedPaths { get; } = []; + + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestedPaths.Add(request.RequestUri!.AbsolutePath); + return responder(request); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(Send(request, cancellationToken)); + } +} diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogFetcherTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogFetcherTests.cs index b87cb5ba6..37352326c 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogFetcherTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogFetcherTests.cs @@ -351,6 +351,58 @@ public async Task FetchAsync_WithETag_ReadsCacheFromDisk() errors.Should().BeEmpty(); } + [Fact] + public async Task FetchNamedBundleAsync_DownloadsParentAndSiblingAmendsOnly() + { + // language=yaml + const string amendBundle = """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - type: bug-fix + title: Amended fix + """; + var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch + { + var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => + Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.4.0.yaml", "target": "9.4.0" }, { "file": "9.3.0.yaml", "target": "9.3.0" }, { "file": "9.3.0.amend-1.yaml", "target": "9.3.0" } ] }"""), + var p when p.EndsWith("/9.3.0.amend-1.yaml", StringComparison.Ordinal) => Yaml(amendBundle), + _ => Yaml(SampleBundle) + }); + var (errors, _, emitError, _) = Diagnostics(); + + using var fetcher = CreateFetcher(handler); + var named = await fetcher.FetchNamedBundleAsync(BaseUri, "elasticsearch", "9.3.0.yaml", emitError, TestContext.Current.CancellationToken); + + errors.Should().BeEmpty(); + named.Should().NotBeNull(); + named!.Value.FileName.Should().Be("9.3.0.yaml"); + named.Value.Content.Should().Contain("Sample enhancement"); + named.Value.AmendSidecars.Should().ContainSingle().Which.FileName.Should().Be("9.3.0.amend-1.yaml"); + handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/registry.json"); + handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/9.3.0.yaml"); + handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/9.3.0.amend-1.yaml"); + handler.RequestedPaths.Should().NotContain(p => p.EndsWith("/9.4.0.yaml", StringComparison.Ordinal)); + } + + [Fact] + public async Task FetchNamedBundleAsync_UnknownFile_EmitsErrorWithoutDownloadingYaml() + { + var handler = new StubHandler(req => + req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) + ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0" } ] }""") + : Yaml(SampleBundle)); + var (errors, _, emitError, _) = Diagnostics(); + + using var fetcher = CreateFetcher(handler); + var named = await fetcher.FetchNamedBundleAsync(BaseUri, "elasticsearch", "missing.yaml", emitError, TestContext.Current.CancellationToken); + + named.Should().BeNull(); + errors.Should().ContainSingle(e => e.Contains("missing.yaml") && e.Contains("not listed")); + handler.RequestedPaths.Should().Equal("/bundle/elasticsearch/registry.json"); + } + [Fact] public async Task FetchAsync_NullETag_AlwaysFetchesFromCdn() { diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogKeysTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogKeysTests.cs index 707e0f9e2..74234cde3 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogKeysTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogKeysTests.cs @@ -260,4 +260,34 @@ public void IsRegistry_ValidArtifactRootKeys_ReturnsTrue(string key) => [InlineData("changelog/elastic/elastic search/main/registry.json")] public void IsRegistry_InvalidKeys_ReturnsFalse(string key) => ChangelogKeys.IsRegistry(key).Should().BeFalse(); + + [Theory] + [InlineData("/bundle/elasticsearch/9.3.0.yaml", "elasticsearch", "9.3.0.yaml")] + [InlineData("bundle/elasticsearch/9.3.0.yaml", "elasticsearch", "9.3.0.yaml")] + [InlineData("https://cdn.example/bundle/elasticsearch/9.3.0.yaml", "elasticsearch", "9.3.0.yaml")] + [InlineData("https://cdn.example/prefix/bundle/kibana/9.3.0.yaml", "kibana", "9.3.0.yaml")] + [InlineData("/bundle/elasticsearch/9.3.0.amend-1.yaml", "elasticsearch", "9.3.0.amend-1.yaml")] + public void TryParseBundleLocator_BundlePaths_ReturnsProductAndFile(string input, string product, string fileName) + { + var parsed = ChangelogKeys.TryParseBundleLocator(input, out var parsedProduct, out var parsedFile); + parsed.Should().BeTrue(); + parsedProduct.Should().Be(product); + parsedFile.Should().Be(fileName); + } + + [Theory] + [InlineData("/changelog/elastic/kibana/main/entry.yaml")] + [InlineData("https://cdn.example/changelog/elastic/kibana/main/entry.yaml")] + [InlineData("/bundle/elasticsearch")] + [InlineData("not-a-path")] + [InlineData("bundle/foo.bar/9.3.0.yaml")] + [InlineData("/bundle/elasticsearch/a/b.yaml")] + [InlineData("")] + [InlineData(null)] + public void TryParseBundleLocator_NonBundlePaths_ReturnsFalse(string? input) + { + ChangelogKeys.TryParseBundleLocator(input, out var product, out var fileName).Should().BeFalse(); + product.Should().BeNull(); + fileName.Should().BeNull(); + } }