diff --git a/docs/_docset.yml b/docs/_docset.yml index 97e0006e6..ac1f9c804 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -164,8 +164,9 @@ toc: - file: index.md - file: changelog-add.md - file: changelog-bundle.md - - file: changelog-init.md - file: changelog-bundle-amend.md + - file: changelog-init.md + - file: changelog-remove.md - file: changelog-render.md - folder: mcp children: diff --git a/docs/cli/release/changelog-bundle.md b/docs/cli/release/changelog-bundle.md index fda2266fe..c8ded7003 100644 --- a/docs/cli/release/changelog-bundle.md +++ b/docs/cli/release/changelog-bundle.md @@ -47,9 +47,9 @@ These arguments apply to profile-based bundling: `--input-products ?>` : Filter by products in format "product target lifecycle, ..." : Only one filter option can be specified: `--all`, `--input-products`, `--prs`, or `--issues`. -: When specified, all three parts (product, target, lifecycle) are required but can be wildcards (`*`). For example: +: When specified, all three parts (product, target, lifecycle) are required but can be wildcards (`*`). Multiple comma-separated values are combined with OR: a changelog is included if it matches any of the specified product/target/lifecycle combinations. For example: -- `"cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta"` - exact matches +- `"cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta"` — include changelogs for either cloud-serverless 2025-12-02 ga or cloud-serverless 2025-12-06 beta - `"cloud-serverless 2025-12-02 *"` - match cloud-serverless 2025-12-02 with any lifecycle - `"elasticsearch * *"` - match all elasticsearch changelogs - `"* 9.3.* *"` - match any product with target starting with "9.3." diff --git a/docs/cli/release/changelog-remove.md b/docs/cli/release/changelog-remove.md new file mode 100644 index 000000000..3ada6714c --- /dev/null +++ b/docs/cli/release/changelog-remove.md @@ -0,0 +1,69 @@ +# changelog remove + +Remove changelog YAML files from a directory. + +You can remove changelogs based their issues, pull requests, or product metadata. +Alternatively, remove all changelogs from the specified directory. +Exactly one filter option must be specified. + +Before deleting anything, the command checks whether any of the matching files are referenced by unresolved bundles, to prevent silently breaking the `{changelog}` directive. + +For more context, go to [](/contribute/changelog.md#changelog-remove). + +## Usage + +```sh +docs-builder changelog remove [options...] [-h|--help] +``` + +## Options + +`--all` +: Remove all changelog files in the directory. +: Exactly one filter option must be specified: `--all`, `--products`, `--prs`, or `--issues`. + +`--bundles-dir ` +: Optional: Override the directory scanned for bundles during the dependency check. +: When not specified, the directory is discovered automatically from config or fallback paths. + +`--config ` +: Optional: Path to the changelog configuration file. +: Defaults to `docs/changelog.yml`. + +`--directory ` +: Optional: The directory that contains the changelog YAML files. +: When not specified, uses `bundle.directory` from the changelog configuration if set, otherwise the current directory. + +`--dry-run` +: Print the files that would be removed and any bundle dependency conflicts, without deleting anything. + +`--force` +: Proceed with removal even when files are referenced by unresolved bundles. +: Emits a warning per dependency instead of blocking. + +`--issues ` +: Filter by issue URLs or numbers (comma-separated), or a path to a newline-delimited file containing issue URLs or numbers. +: Can be specified multiple times. +: Exactly one filter option must be specified: `--all`, `--products`, `--prs`, or `--issues`. + +`--owner ` +: The GitHub repository owner, which is required when pull requests or issues are specified as numbers. + +`--products ?>` +: Filter by products in format `"product target lifecycle, ..."` +: Exactly one filter option must be specified: `--all`, `--products`, `--prs`, or `--issues`. +: All three parts (product, target, lifecycle) are required but can be wildcards (`*`). Multiple comma-separated values are combined with OR: a changelog is removed if it matches any of the specified product/target/lifecycle combinations. For example: + +- `"elasticsearch 9.3.0 ga"` — exact match +- `"cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta"` — remove changelogs for either cloud-serverless 2025-12-02 ga or cloud-serverless 2025-12-06 beta +- `"elasticsearch * *"` — all elasticsearch changelogs +- `"* 9.3.* *"` — any product with a target starting with `9.3.` +- `"* * *"` — all changelogs (equivalent to `--all`) + +`--prs ` +: Filter by pull request URLs or numbers (comma-separated), or a path to a newline-delimited file containing PR URLs or numbers. +: Can be specified multiple times. +: Exactly one filter option must be specified: `--all`, `--products`, `--prs`, or `--issues`. + +`--repo ` +: The GitHub repository name, which is required when pull requests or issues are specified as numbers. diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index 48590f524..b93a3a0a3 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -490,9 +490,14 @@ You can specify only one of the following filter options: - `--prs`: Include changelogs for the specified pull request URLs or numbers, or a path to a newline-delimited file containing PR URLs or numbers. Go to [Filter by pull requests](#changelog-bundle-pr). - `--issues`: Include changelogs for the specified issue URLs or numbers, or a path to a newline-delimited file containing issue URLs or numbers. Go to [Filter by issues](#changelog-bundle-issues). -By default, the output file contains only the changelog file names and checksums. +By default, the output file contains only the changelog file names and checksums unless you set `bundle.resolve` to `true` in the changelog configuration file. You can optionally use the `--resolve` command option to pull all of the content from each changelog into the bundle. +:::{tip} +If you plan to use [changelog directives](#changelog-directive), it is recommended to use "resolved" bundles; otherwise you can't delete your changelogs. +If you likewise want to regenerate your [Asciidoc or Markdown files](#render-changelogs) after deleting your changelogs, it's only possible if you have resolved bundles. +::: + When you do not specify `--directory`, the command reads changelog files from `bundle.directory` in your changelog configuration if it is set, otherwise from the current directory. When you do not specify `--output`, the command writes the bundle to `bundle.output_directory` from your changelog configuration (creating `changelog-bundle.yaml` in that directory) if it is set, otherwise to `changelog-bundle.yaml` in the input directory. @@ -548,8 +553,6 @@ entries: 1. By default these values match your `--input-products` (even if the changelogs have more products). To specify different product metadata, use the `--output-products` option. -If you add the `--resolve` option, the contents of each changelog will be included in the output file. - ### Filter by pull requests [changelog-bundle-pr] You can use the `--prs` option to create a bundle of the changelogs that relate to those pull requests. @@ -590,8 +593,6 @@ entries: checksum: 451d60283fe5df426f023e824339f82c2900311e ``` -If you add the `--resolve` option, the contents of each changelog will be included in the output file. - ### Filter by issues [changelog-bundle-issues] You can use the `--issues` option to create a bundle of changelogs that relate to those GitHub issues. @@ -821,3 +822,35 @@ highlight: true ``` When rendering changelogs, entries with `highlight: true` are collected from all types and rendered in a dedicated highlights section. In markdown output, this creates a separate `highlights.md` file. In asciidoc output, highlights appear as a dedicated section in the single asciidoc file. + +## Remove changelog files [changelog-remove] + +A single changelog file might be applicable to multiple releases (for example, it might be delivered in both Stack and {{serverless-short}} releases or {{ech}} and Enterprise releases on different timelines). +After it has been included in all of the relevant bundles, it is reasonable to delete the changelog to keep your repository clean. + +:::{important} +If you create docs with changelog directives, run the `docs-builder changelog bundle` command with the `--resolve` option or set `bundle.resolve` to `true` in the changelog configuration file (so that bundle files are self-contained). +Otherwise, the build will fail if you remove changelogs that the directive requires. + +Likewise, the `docs-builder changelog render` command fails for "unresolved" bundles after you delete the changelogs. +::: + +You can use the `docs-builder changelog remove` command to remove changelogs. +It has the same filter options as `changelog bundle` (that is to say, you can remove changelogs based their issues or pull requests, product metadata, or folder). +Exactly one filter option must be specified. + +Before deleting, the command automatically scans for bundles that still hold unresolved (`file:`) references to the matching changelog files. +If any are found, the command reports an error for each dependency. +This check prevents the `{changelog}` directive from failing at build time with missing file errors. +To proceed with removal even when unresolved bundle dependencies exist, use `--force`. + +To preview what would be removed without deleting anything, use `--dry-run`. +Bundle dependency conflicts are also reported in dry-run mode. + +For example: + +```sh +docs-builder changelog remove --products "elasticsearch 9.3.0 *" --dry-run +``` + +For full option details, go to [](/cli/release/changelog-remove.md). diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index d99d873b6..56fe11d4a 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -418,6 +418,8 @@ docs/ └── release-notes.md # Page with :::{changelog} ``` +To override these expectations, set the `bundle.directory` and `bundle.output_directory` in the changelog configuration file. + ## Version ordering Bundles are automatically sorted by **semantic version** (descending - newest first). This means: @@ -466,6 +468,24 @@ Each bundle renders as a `## {version}` section with subsections beneath: Sections with no entries of that type are omitted from the output. +## Error behavior for missing files [changelog-missing-files] + +Bundles created without the `--resolve` option store `file:` references (filenames and checksums) instead of embedding entry content inline. +When the directive loads such a bundle, it looks up each referenced file to read its content. +If a referenced file cannot be found on disk, the directive emits an error and the build fails. +This prevents silent data loss where changelog entries would be quietly omitted from rendered release notes without any indication that something was missing. + +To fix this, either: + +- Restore the missing changelog files, or +- Re-create the bundle with `--resolve` to embed entry content directly (making the bundle self-contained), or +- Remove the unresolvable entry from the bundle file. + +:::{tip} +In general, if you want to be able to remove changelog files after your releases, create your bundles with the `--resolve` option or set `bundle.resolve` to `true` in the changelog configuration file. +For more command syntax details, go to [Remove changelog files](../contribute/changelog.md#changelog-remove). +::: + ## Example The following renders all changelog bundles from the default `changelog/bundles/` folder: @@ -495,4 +515,5 @@ The `{changelog}` directive is ideal for release notes pages that should always - [Create and bundle changelogs](../contribute/changelog.md) — Learn how to create changelog entries and bundles - [`changelog add`](../cli/release/changelog-add.md) — CLI command to create changelog entries - [`changelog bundle`](../cli/release/changelog-bundle.md) — CLI command to bundle changelog entries +- [`changelog remove`](../cli/release/changelog-remove.md) — CLI command to remove changelog files - [`changelog render`](../cli/release/changelog-render.md) — CLI command to render changelogs to markdown files diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index a864b5b79..9d41ed342 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs @@ -326,9 +326,11 @@ private void LoadAndCacheBundles() var loader = new BundleLoader(Build.ReadFileSystem); // Load bundles using the BundleLoader service + // Emit errors (not warnings) for missing file references so the build fails fast + // rather than silently omitting entries from the rendered output. var loadedBundles = loader.LoadBundles( BundlesFolderPath, - msg => this.EmitWarning(msg)); + msg => this.EmitError(msg)); // Sort by version (descending - newest first) // Supports both semver (e.g., "9.3.0") and date-based (e.g., "2025-08-05") versions diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs new file mode 100644 index 000000000..7437b181e --- /dev/null +++ b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs @@ -0,0 +1,386 @@ +// 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.IO.Abstractions; +using System.Linq; +using Elastic.Changelog.Configuration; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.Configuration.ReleaseNotes; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Services; +using Microsoft.Extensions.Logging; + +namespace Elastic.Changelog.Bundling; + +/// +/// Arguments for the method. +/// +public record ChangelogRemoveArguments +{ + public required string Directory { get; init; } + public bool All { get; init; } + public IReadOnlyList? Products { get; init; } + public string[]? Prs { get; init; } + public string[]? Issues { get; init; } + public string? Owner { get; init; } + public string? Repo { get; init; } + public bool DryRun { get; init; } + public string? BundlesDir { get; init; } + public bool Force { get; init; } + public string? Config { get; init; } +} + +/// +/// A discovered dependency between a changelog file and the bundle(s) that reference it. +/// +public record BundleDependency(string ChangelogFile, string BundleFile); + +/// +/// Service for removing changelog files based on the same filter options as . +/// +public class ChangelogRemoveService( + ILoggerFactory logFactory, + IConfigurationContext? configurationContext = null, + IFileSystem? fileSystem = null) + : IService +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); + private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null + ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? new FileSystem()) + : null; + + public async Task RemoveChangelogs(IDiagnosticsCollector collector, ChangelogRemoveArguments input, Cancel ctx) + { + try + { + ChangelogConfiguration? config = null; + if (_configLoader != null) + config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx); + + input = ApplyConfigDefaults(input, config); + + if (!ValidateInput(collector, input)) + return false; + + var prsToMatch = new HashSet(StringComparer.OrdinalIgnoreCase); + var issuesToMatch = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (input.Prs is { Length: > 0 }) + { + var loader = new PrFilterLoader(_fileSystem); + var result = await loader.LoadPrsAsync(collector, input.Prs, input.Owner, input.Repo, ctx); + if (!result.IsValid) + return false; + prsToMatch = result.PrsToMatch; + } + else if (input.Issues is { Length: > 0 }) + { + var loader = new IssueFilterLoader(_fileSystem); + var result = await loader.LoadIssuesAsync(collector, input.Issues, input.Owner, input.Repo, ctx); + if (!result.IsValid) + return false; + issuesToMatch = result.IssuesToMatch; + } + + // A placeholder output path is passed to discovery so the bundle file itself is excluded. + var placeholderOutput = _fileSystem.Path.Combine(input.Directory, "changelog-bundle.yaml"); + var fileDiscovery = new ChangelogFileDiscovery(_fileSystem, _logger); + var yamlFiles = await fileDiscovery.DiscoverChangelogFilesAsync(input.Directory, placeholderOutput, ctx); + + if (yamlFiles.Count == 0) + { + collector.EmitError(input.Directory, "No changelog YAML files found in directory"); + return false; + } + + var filterCriteria = BuildFilterCriteria(input, prsToMatch, issuesToMatch); + var entryMatcher = new ChangelogEntryMatcher(_fileSystem, ReleaseNotesSerialization.GetEntryDeserializer(), _logger); + var matchResult = await entryMatcher.MatchChangelogsAsync(collector, yamlFiles, filterCriteria, ctx); + + if (matchResult.Entries.Count == 0) + { + collector.EmitError(string.Empty, "No changelog entries matched the filter criteria"); + return false; + } + + var filesToRemove = matchResult.Entries + .Select(e => e.FilePath) + .ToList(); + + // Check bundle dependencies before deleting + var dependencies = await FindBundleDependenciesAsync(input, filesToRemove, config, ctx); + + if (dependencies.Count > 0) + { + foreach (var dep in dependencies) + { + var proceedHint = input.Force ? "" : " To proceed anyway, use --force."; + var message = + $"Changelog file '{_fileSystem.Path.GetFileName(dep.ChangelogFile)}' is referenced by " + + $"unresolved bundle '{dep.BundleFile}'." + + $" Removing it will cause the {{changelog}} directive to fail when loading that bundle." + + $" To make the bundle self-contained, re-run: docs-builder changelog bundle --resolve ..." + + $"{proceedHint}"; + + if (input.Force) + collector.EmitWarning(dep.ChangelogFile, message); + else + collector.EmitError(dep.ChangelogFile, message); + } + + if (!input.Force) + return false; + } + + if (input.DryRun) + { + _logger.LogInformation("[dry-run] Would remove {Count} changelog file(s):", filesToRemove.Count); + foreach (var file in filesToRemove) + _logger.LogInformation("[dry-run] {File}", file); + return true; + } + + foreach (var file in filesToRemove) + { + _fileSystem.File.Delete(file); + _logger.LogInformation("Removed: {File}", file); + } + + _logger.LogInformation("Removed {Count} changelog file(s).", filesToRemove.Count); + return true; + } + catch (IOException ioEx) + { + collector.EmitError(string.Empty, $"IO error removing changelogs: {ioEx.Message}", ioEx); + return false; + } + catch (UnauthorizedAccessException uaEx) + { + collector.EmitError(string.Empty, $"Access denied removing changelogs: {uaEx.Message}", uaEx); + return false; + } + } + + private ChangelogRemoveArguments ApplyConfigDefaults(ChangelogRemoveArguments input, ChangelogConfiguration? config) + { + if (config?.Bundle == null) + return input; + + var directory = input.Directory; + if ((string.IsNullOrWhiteSpace(directory) || directory == _fileSystem.Directory.GetCurrentDirectory()) + && !string.IsNullOrWhiteSpace(config.Bundle.Directory)) + directory = config.Bundle.Directory; + + return input with { Directory = directory }; + } + + private bool ValidateInput(IDiagnosticsCollector collector, ChangelogRemoveArguments input) + { + if (string.IsNullOrWhiteSpace(input.Directory)) + { + collector.EmitError(string.Empty, "Directory is required"); + return false; + } + + if (!_fileSystem.Directory.Exists(input.Directory)) + { + collector.EmitError(input.Directory, "Directory does not exist"); + return false; + } + + var specified = new List(); + if (input.All) + specified.Add("--all"); + if (input.Products is { Count: > 0 }) + specified.Add("--products"); + if (input.Prs is { Length: > 0 }) + specified.Add("--prs"); + if (input.Issues is { Length: > 0 }) + specified.Add("--issues"); + + if (specified.Count == 0) + { + collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --products, --prs, or --issues"); + return false; + } + + if (specified.Count > 1) + { + collector.EmitError(string.Empty, + $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specified)}. Please use only one filter option: --all, --products, --prs, or --issues"); + return false; + } + + return true; + } + + private static ChangelogFilterCriteria BuildFilterCriteria( + ChangelogRemoveArguments input, + HashSet prsToMatch, + HashSet issuesToMatch) + { + var productFilters = new List(); + if (input.Products is { Count: > 0 }) + { + foreach (var product in input.Products) + { + productFilters.Add(new ProductFilter + { + ProductPattern = product.Product == "*" ? null : product.Product, + TargetPattern = product.Target == "*" ? null : product.Target, + LifecyclePattern = product.Lifecycle == "*" ? null : product.Lifecycle + }); + } + } + + return new ChangelogFilterCriteria + { + IncludeAll = input.All, + ProductFilters = productFilters, + PrsToMatch = prsToMatch, + IssuesToMatch = issuesToMatch, + DefaultOwner = input.Owner, + DefaultRepo = input.Repo + }; + } + + /// + /// Discovers which files to be removed are referenced by unresolved bundles. + /// Bundle locations are discovered automatically unless overridden by . + /// + private async Task> FindBundleDependenciesAsync( + ChangelogRemoveArguments input, + IReadOnlyList filesToRemove, + ChangelogConfiguration? config, + Cancel ctx) + { + var bundlesDir = ResolveBundlesDirectory(input, config); + if (bundlesDir is null) + return []; + + var bundleFiles = _fileSystem.Directory + .GetFiles(bundlesDir, "*.yaml", SearchOption.AllDirectories) + .Concat(_fileSystem.Directory.GetFiles(bundlesDir, "*.yml", SearchOption.AllDirectories)) + .ToList(); + + if (bundleFiles.Count == 0) + return []; + + // Build a set of file names to remove (just basenames, since bundle entries store basenames) + var toRemoveNames = new HashSet( + filesToRemove.Select(f => _fileSystem.Path.GetFileName(f)), + StringComparer.OrdinalIgnoreCase); + + var dependencies = new List(); + + foreach (var bundleFile in bundleFiles) + { + try + { + var content = await _fileSystem.File.ReadAllTextAsync(bundleFile, ctx); + var bundle = ReleaseNotesSerialization.DeserializeBundle(content); + + // Only treat as unresolved when the entry would need to load from file. + // Resolved entries have inline data (Title+Type) and don't need the file even if they have a File block. + var entryFileNames = bundle.Entries + .Where(entry => + !string.IsNullOrWhiteSpace(entry.File?.Name) && + (string.IsNullOrWhiteSpace(entry.Title) || entry.Type == null)) + .Select(entry => NormalizeEntryFileName(entry.File!.Name)); + + foreach (var entryFileName in entryFileNames.Where(entryFileName => toRemoveNames.Contains(entryFileName))) + { + // bundle entry.File.Name is relative to the changelog directory (parent of bundles dir) + // Normalize to just the base filename for comparison + + // Find the full path from filesToRemove that matches this entry + var matchingFile = filesToRemove + .FirstOrDefault(f => string.Equals( + _fileSystem.Path.GetFileName(f), + entryFileName, + StringComparison.OrdinalIgnoreCase)); + + if (matchingFile is not null) + dependencies.Add(new BundleDependency(matchingFile, bundleFile)); + } + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or ThreadAbortException)) + { + _logger.LogWarning(ex, "Could not parse bundle file {BundleFile} for dependency check", bundleFile); + } + } + + return dependencies; + } + + /// + /// Resolves the bundles directory using: explicit override, then config, then fallbacks. + /// Returns null if no bundles directory can be found. + /// + private string? ResolveBundlesDirectory(ChangelogRemoveArguments input, ChangelogConfiguration? config) + { + // 1. Explicit override + if (!string.IsNullOrWhiteSpace(input.BundlesDir)) + { + if (_fileSystem.Directory.Exists(input.BundlesDir)) + return input.BundlesDir; + _logger.LogWarning("Specified --bundles-dir '{BundlesDir}' does not exist, skipping dependency check", input.BundlesDir); + return null; + } + + // 2. Config bundle.output_directory (resolve relative paths against config file location) + var outputDir = config?.Bundle?.OutputDirectory; + if (!string.IsNullOrWhiteSpace(outputDir)) + { + var resolvedOutputDir = ResolveOutputDirectory(outputDir, input.Config); + if (_fileSystem.Directory.Exists(resolvedOutputDir)) + return resolvedOutputDir; + } + + // 3. {directory}/bundles + var sibling = _fileSystem.Path.Combine(input.Directory, "bundles"); + if (_fileSystem.Directory.Exists(sibling)) + return sibling; + + // 4. {directory}/../bundles + var dirParent = _fileSystem.Path.GetDirectoryName(input.Directory); + if (!string.IsNullOrWhiteSpace(dirParent)) + { + var parentBundles = _fileSystem.Path.Combine(dirParent, "bundles"); + if (_fileSystem.Directory.Exists(parentBundles)) + return parentBundles; + } + + return null; + } + + private string ResolveOutputDirectory(string outputDirectory, string? configPath) + { + if (_fileSystem.Path.IsPathRooted(outputDirectory)) + return outputDirectory; + + if (string.IsNullOrWhiteSpace(configPath)) + return _fileSystem.Path.GetFullPath(outputDirectory); + + var configDir = _fileSystem.Path.GetDirectoryName(configPath); + if (string.IsNullOrWhiteSpace(configDir)) + return _fileSystem.Path.GetFullPath(outputDirectory); + + var repoRoot = _fileSystem.Path.GetDirectoryName(configDir); + if (string.IsNullOrWhiteSpace(repoRoot)) + return _fileSystem.Path.GetFullPath(outputDirectory); + + return _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(repoRoot, outputDirectory)); + } + + private static string NormalizeEntryFileName(string entryFileName) + { + // Entry names can be paths like "subdir/file.yaml" — take just the file name for comparison + var normalized = entryFileName.Replace('\\', '/'); + var slashIdx = normalized.LastIndexOf('/'); + return slashIdx >= 0 ? normalized[(slashIdx + 1)..] : normalized; + } +} diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 6a947c398..0ab1934c3 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -650,6 +650,137 @@ async static (s, collector, state, ctx) => await s.BundleChangelogs(collector, s return await serviceInvoker.InvokeAsync(ctx); } + /// + /// Remove changelog files. Exactly one filter option must be specified: --all, --products, --prs, or --issues. + /// When a file is referenced by an unresolved bundle, the command blocks by default to prevent breaking + /// the {changelog} directive. Use --force to override. + /// + /// Remove all changelogs in the directory. Exactly one filter option must be specified: --all, --products, --prs, or --issues. + /// Optional: Override the directory that is scanned for bundles during the dependency check. Auto-discovered from config or fallback paths when not specified. + /// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' + /// Optional: Directory containing changelog YAML files. Uses config bundle.directory or defaults to current directory + /// Print the files that would be removed without deleting them. + /// Proceed with removal even when files are referenced by unresolved bundles. Emits warnings instead of errors for each dependency. + /// Filter by issue URLs or numbers (comma-separated), or a path to a newline-delimited file containing issue URLs or numbers. Can be specified multiple times. Exactly one filter option must be specified: --all, --products, --prs, or --issues. + /// GitHub repository owner (required when PRs or issues are specified as numbers) + /// Filter by products in format "product target lifecycle, ..." (e.g., "elasticsearch 9.3.0 ga"). All three parts are required but can be wildcards (*). Exactly one filter option must be specified: --all, --products, --prs, or --issues. + /// Filter by pull request URLs or numbers (comma-separated), or a path to a newline-delimited file. Can be specified multiple times. Exactly one filter option must be specified: --all, --products, --prs, or --issues. + /// GitHub repository name (required when PRs or issues are specified as numbers) + /// + [Command("remove")] + public async Task Remove( + bool all = false, + string? bundlesDir = null, + string? config = null, + string? directory = null, + bool dryRun = false, + bool force = false, + string[]? issues = null, + string? owner = null, + [ProductInfoParser] List? products = null, + string[]? prs = null, + string? repo = null, + Cancel ctx = default + ) + { + await using var serviceInvoker = new ServiceInvoker(collector); + + var service = new ChangelogRemoveService(logFactory, configurationContext); + + // Expand comma-separated --prs values + var allPrs = new List(); + if (prs is { Length: > 0 }) + { + foreach (var value in prs.Where(p => !string.IsNullOrWhiteSpace(p))) + { + if (value.Contains(',')) + allPrs.AddRange(value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + else + allPrs.Add(value); + } + } + + // Expand comma-separated --issues values + var allIssues = new List(); + if (issues is { Length: > 0 }) + { + foreach (var value in issues.Where(p => !string.IsNullOrWhiteSpace(p))) + { + if (value.Contains(',')) + allIssues.AddRange(value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + else + allIssues.Add(value); + } + } + + // Validate product filter: all three parts (product, target, lifecycle) must be present (can be *) + if (products is { Count: > 0 }) + { + foreach (var product in products) + { + if (string.IsNullOrWhiteSpace(product.Product)) + { + collector.EmitError(string.Empty, "--products: product is required (use '*' for wildcard)"); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + + if (product.Target == null) + { + collector.EmitError(string.Empty, $"--products: target is required for product '{product.Product}' (use '*' for wildcard)"); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + + if (product.Lifecycle == null) + { + collector.EmitError(string.Empty, $"--products: lifecycle is required for product '{product.Product}' (use '*' for wildcard)"); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + } + + // --products * * * is equivalent to --all + var isAllWildcard = products.Count == 1 && + products[0].Product == "*" && + products[0].Target == "*" && + products[0].Lifecycle == "*"; + + if (isAllWildcard) + { + all = true; + products = null; + } + } + + var input = new ChangelogRemoveArguments + { + Directory = NormalizePath(directory ?? Directory.GetCurrentDirectory()), + All = all, + Products = products, + Prs = allPrs.Count > 0 ? allPrs.ToArray() : null, + Issues = allIssues.Count > 0 ? allIssues.ToArray() : null, + Owner = owner, + Repo = repo, + DryRun = dryRun, + BundlesDir = string.IsNullOrWhiteSpace(bundlesDir) ? null : NormalizePath(bundlesDir), + Force = force, + Config = string.IsNullOrWhiteSpace(config) ? null : NormalizePath(config) + }; + + serviceInvoker.AddCommand(service, input, + async static (s, collector, state, ctx) => await s.RemoveChangelogs(collector, state, ctx) + ); + + return await serviceInvoker.InvokeAsync(ctx); + } + /// /// Render bundled changelog(s) to markdown or asciidoc files /// diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs new file mode 100644 index 000000000..f1334d6f1 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs @@ -0,0 +1,437 @@ +// 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 Elastic.Changelog.Bundling; +using Elastic.Documentation.Diagnostics; +using FluentAssertions; + +namespace Elastic.Changelog.Tests.Changelogs; + +public class ChangelogRemoveTests : ChangelogTestBase +{ + private ChangelogRemoveService Service { get; } + private readonly string _changelogDir; + + // language=yaml + private const string ElasticsearchFeatureYaml = + """ + title: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/1001 + """; + + // language=yaml + private const string KibanaFeatureYaml = + """ + title: Kibana feature + type: feature + products: + - product: kibana + target: 9.3.0 + lifecycle: ga + prs: + - https://github.com/elastic/kibana/pull/2001 + """; + + // language=yaml + private const string ElasticsearchBugFixYaml = + """ + title: Elasticsearch bug fix + type: bug-fix + products: + - product: elasticsearch + target: 9.3.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/1002 + issues: + - https://github.com/elastic/elasticsearch/issues/9999 + """; + + public ChangelogRemoveTests(ITestOutputHelper output) : base(output) + { + Service = new ChangelogRemoveService(LoggerFactory, null, FileSystem); + _changelogDir = CreateChangelogDir(); + } + + private string CreateChangelogDir() + { + var dir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(dir); + return dir; + } + + private async Task WriteFile(string fileName, string content) + { + var path = FileSystem.Path.Combine(_changelogDir, fileName); + await FileSystem.File.WriteAllTextAsync(path, content, TestContext.Current.CancellationToken); + } + + private bool FileExists(string fileName) => + FileSystem.File.Exists(FileSystem.Path.Combine(_changelogDir, fileName)); + + // ------------------------------------------------------------------ + // Basic filter tests + // ------------------------------------------------------------------ + + [Fact] + public async Task Remove_WithAll_DeletesAllFiles() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + await WriteFile("2001-kibana-feature.yaml", KibanaFeatureYaml); + + var input = new ChangelogRemoveArguments { Directory = _changelogDir, All = true }; + + var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + FileExists("1001-es-feature.yaml").Should().BeFalse(); + FileExists("2001-kibana-feature.yaml").Should().BeFalse(); + } + + [Fact] + public async Task Remove_WithProducts_DeletesMatchingOnly() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + await WriteFile("2001-kibana-feature.yaml", KibanaFeatureYaml); + + var input = new ChangelogRemoveArguments + { + Directory = _changelogDir, + Products = [new ProductArgument { Product = "elasticsearch", Target = "*", Lifecycle = "*" }] + }; + + var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + FileExists("1001-es-feature.yaml").Should().BeFalse("Elasticsearch changelog should be removed"); + FileExists("2001-kibana-feature.yaml").Should().BeTrue("Kibana changelog should be kept"); + } + + [Fact] + public async Task Remove_WithPrs_DeletesMatchingOnly() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + await WriteFile("2001-kibana-feature.yaml", KibanaFeatureYaml); + + var input = new ChangelogRemoveArguments + { + Directory = _changelogDir, + Prs = ["https://github.com/elastic/elasticsearch/pull/1001"] + }; + + var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + FileExists("1001-es-feature.yaml").Should().BeFalse("Matched changelog should be removed"); + FileExists("2001-kibana-feature.yaml").Should().BeTrue("Unmatched changelog should be kept"); + } + + [Fact] + public async Task Remove_WithIssues_DeletesMatchingOnly() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + await WriteFile("1002-es-bugfix.yaml", ElasticsearchBugFixYaml); + + var input = new ChangelogRemoveArguments + { + Directory = _changelogDir, + Issues = ["https://github.com/elastic/elasticsearch/issues/9999"] + }; + + var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + FileExists("1001-es-feature.yaml").Should().BeTrue("Non-matching changelog should be kept"); + FileExists("1002-es-bugfix.yaml").Should().BeFalse("Issue-matched changelog should be removed"); + } + + // ------------------------------------------------------------------ + // Dry-run + // ------------------------------------------------------------------ + + [Fact] + public async Task Remove_WithDryRun_DoesNotDelete() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + await WriteFile("2001-kibana-feature.yaml", KibanaFeatureYaml); + + var input = new ChangelogRemoveArguments { Directory = _changelogDir, All = true, DryRun = true }; + + var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + FileExists("1001-es-feature.yaml").Should().BeTrue("Dry-run must not delete files"); + FileExists("2001-kibana-feature.yaml").Should().BeTrue("Dry-run must not delete files"); + } + + // ------------------------------------------------------------------ + // Validation + // ------------------------------------------------------------------ + + [Fact] + public async Task Remove_WithNoFilter_EmitsError() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + + var input = new ChangelogRemoveArguments { Directory = _changelogDir }; + + var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().ContainSingle(d => + d.Severity == Severity.Error && + d.Message.Contains("At least one filter option")); + } + + [Fact] + public async Task Remove_WithMultipleFilters_EmitsError() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + + var input = new ChangelogRemoveArguments + { + Directory = _changelogDir, + All = true, + Prs = ["https://github.com/elastic/elasticsearch/pull/1001"] + }; + + var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().ContainSingle(d => + d.Severity == Severity.Error && + d.Message.Contains("Multiple filter options cannot be specified together")); + } + + [Fact] + public async Task Remove_WithNoMatchingChangelogs_EmitsError() + { + await WriteFile("2001-kibana-feature.yaml", KibanaFeatureYaml); + + var input = new ChangelogRemoveArguments + { + Directory = _changelogDir, + Products = [new ProductArgument { Product = "elasticsearch", Target = "*", Lifecycle = "*" }] + }; + + var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().ContainSingle(d => + d.Severity == Severity.Error && + d.Message.Contains("No changelog entries matched")); + } + + // ------------------------------------------------------------------ + // Bundle dependency checks + // ------------------------------------------------------------------ + + [Fact] + public async Task Remove_WhenReferencedByUnresolvedBundle_Blocks() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + + var bundlesDir = FileSystem.Path.Combine(_changelogDir, "bundles"); + FileSystem.Directory.CreateDirectory(bundlesDir); + var checksum = ComputeSha1(ElasticsearchFeatureYaml); + // language=yaml + await FileSystem.File.WriteAllTextAsync( + FileSystem.Path.Combine(bundlesDir, "9.3.0.yaml"), + // language=yaml + $""" + products: + - product: elasticsearch + target: 9.3.0 + entries: + - file: + name: 1001-es-feature.yaml + checksum: {checksum} + """, + TestContext.Current.CancellationToken + ); + + var input = new ChangelogRemoveArguments { Directory = _changelogDir, All = true }; + + var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse("Command should be blocked when a referenced bundle exists"); + Collector.Diagnostics.Should().ContainSingle(d => + d.Severity == Severity.Error && + d.Message.Contains("1001-es-feature.yaml") && + d.Message.Contains("unresolved bundle")); + FileExists("1001-es-feature.yaml").Should().BeTrue("File must not be deleted when blocked"); + } + + [Fact] + public async Task Remove_WhenReferencedByUnresolvedBundle_WithForce_Proceeds() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + + var bundlesDir = FileSystem.Path.Combine(_changelogDir, "bundles"); + FileSystem.Directory.CreateDirectory(bundlesDir); + var checksum = ComputeSha1(ElasticsearchFeatureYaml); + await FileSystem.File.WriteAllTextAsync( + FileSystem.Path.Combine(bundlesDir, "9.3.0.yaml"), + // language=yaml + $""" + products: + - product: elasticsearch + target: 9.3.0 + entries: + - file: + name: 1001-es-feature.yaml + checksum: {checksum} + """, + TestContext.Current.CancellationToken + ); + + var input = new ChangelogRemoveArguments { Directory = _changelogDir, All = true, Force = true }; + + var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue("--force should allow deletion despite dependency"); + Collector.Errors.Should().Be(0, "With --force, errors become warnings"); + Collector.Diagnostics.Should().ContainSingle(d => + d.Severity == Severity.Warning && + d.Message.Contains("1001-es-feature.yaml")); + FileExists("1001-es-feature.yaml").Should().BeFalse("File should be deleted with --force"); + } + + [Fact] + public async Task Remove_WhenReferencedByResolvedBundle_Proceeds() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + + var bundlesDir = FileSystem.Path.Combine(_changelogDir, "bundles"); + FileSystem.Directory.CreateDirectory(bundlesDir); + + // Bundle has ONLY inline (resolved) entries — no file references + await FileSystem.File.WriteAllTextAsync( + FileSystem.Path.Combine(bundlesDir, "9.3.0.yaml"), + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Already resolved entry + type: feature + products: + - product: elasticsearch + target: 9.3.0 + prs: + - https://github.com/elastic/elasticsearch/pull/999 + """, + TestContext.Current.CancellationToken + ); + + var input = new ChangelogRemoveArguments { Directory = _changelogDir, All = true }; + + var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue("Resolved bundles do not block removal"); + Collector.Errors.Should().Be(0); + FileExists("1001-es-feature.yaml").Should().BeFalse("File should be deleted"); + } + + [Fact] + public async Task Remove_WithNoBundlesFound_Proceeds() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + // No bundles directory created — dependency check is skipped + + var input = new ChangelogRemoveArguments { Directory = _changelogDir, All = true }; + + var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue("Removal should proceed when no bundles are found"); + Collector.Errors.Should().Be(0); + FileExists("1001-es-feature.yaml").Should().BeFalse("File should be deleted"); + } + + [Fact] + public async Task Remove_WithBundlesDirOverride_UsesSpecifiedPath() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + + // Create a bundles dir in a custom location + var customBundlesDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(customBundlesDir); + var checksum = ComputeSha1(ElasticsearchFeatureYaml); + await FileSystem.File.WriteAllTextAsync( + FileSystem.Path.Combine(customBundlesDir, "9.3.0.yaml"), + // language=yaml + $""" + products: + - product: elasticsearch + target: 9.3.0 + entries: + - file: + name: 1001-es-feature.yaml + checksum: {checksum} + """, + TestContext.Current.CancellationToken + ); + + var input = new ChangelogRemoveArguments + { + Directory = _changelogDir, + All = true, + BundlesDir = customBundlesDir + }; + + var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse("Custom bundles dir should be scanned and dependency found"); + Collector.Diagnostics.Should().ContainSingle(d => + d.Severity == Severity.Error && + d.Message.Contains("1001-es-feature.yaml")); + } + + [Fact] + public async Task Remove_WithDryRun_ShowsDependencyConflicts() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + + var bundlesDir = FileSystem.Path.Combine(_changelogDir, "bundles"); + FileSystem.Directory.CreateDirectory(bundlesDir); + var checksum = ComputeSha1(ElasticsearchFeatureYaml); + await FileSystem.File.WriteAllTextAsync( + FileSystem.Path.Combine(bundlesDir, "9.3.0.yaml"), + // language=yaml + $""" + products: + - product: elasticsearch + target: 9.3.0 + entries: + - file: + name: 1001-es-feature.yaml + checksum: {checksum} + """, + TestContext.Current.CancellationToken + ); + + // Dry-run WITH dependency — should report error but not delete + var input = new ChangelogRemoveArguments { Directory = _changelogDir, All = true, DryRun = true }; + + var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse("Dependency conflict should still block dry-run result"); + Collector.Diagnostics.Should().ContainSingle(d => + d.Severity == Severity.Error && + d.Message.Contains("1001-es-feature.yaml")); + FileExists("1001-es-feature.yaml").Should().BeTrue("Dry-run must not delete files"); + } +} diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogMissingFileTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogMissingFileTests.cs new file mode 100644 index 000000000..29dc227e5 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogMissingFileTests.cs @@ -0,0 +1,137 @@ +// 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.IO.Abstractions.TestingHelpers; +using Elastic.Documentation.Diagnostics; +using Elastic.Markdown.Myst.Directives.Changelog; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.Directives; + +/// +/// Tests that the changelog directive emits errors (not warnings) when a bundle contains +/// unresolved file references that cannot be found on disk. +/// This ensures builds fail fast rather than silently omitting changelog entries. +/// +public class ChangelogMissingFileReferenceTests : DirectiveTest +{ + public ChangelogMissingFileReferenceTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => + // Bundle references a file that does not exist on disk (unresolved reference) + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - file: + name: 1234-missing-entry.yaml + checksum: abc123 + """));// Intentionally NOT adding docs/changelog/1234-missing-entry.yaml + + [Fact] + public void EmitsErrorForMissingReferencedFile() => + Collector.Diagnostics.Should().ContainSingle(d => + d.Severity == Severity.Error && + d.Message.Contains("1234-missing-entry.yaml") && + d.Message.Contains("not found")); + + [Fact] + public void ErrorIsNotAWarning() => + Collector.Diagnostics.Should().NotContain(d => + d.Severity == Severity.Warning && + d.Message.Contains("1234-missing-entry.yaml")); +} + +/// +/// Tests that the changelog directive does NOT error when a bundle has +/// inline (resolved) entries — files being absent from disk is irrelevant. +/// +public class ChangelogInlineEntriesNoErrorTests : DirectiveTest +{ + public ChangelogInlineEntriesNoErrorTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => + // Bundle has fully inline/resolved entry — no file reference needed + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Inline feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + prs: + - "999" + """)); + + [Fact] + public void HasNoDiagnostics() => + Collector.Diagnostics.Should().BeEmpty(); + + [Fact] + public void LoadsEntries() => + Block!.LoadedBundles.Should().ContainSingle(b => b.Entries.Count == 1); +} + +/// +/// Tests that the changelog directive correctly loads resolved file references +/// when the referenced files exist. +/// +public class ChangelogFileReferenceResolvesCorrectlyTests : DirectiveTest +{ + public ChangelogFileReferenceResolvesCorrectlyTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + // Bundle references a file that DOES exist + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - file: + name: 1234-existing-entry.yaml + checksum: placeholder + """)); + + // Add the referenced file + FileSystem.AddFile("docs/changelog/1234-existing-entry.yaml", new MockFileData( + // language=yaml + """ + title: An existing feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + prs: + - "1234" + """)); + } + + [Fact] + public void HasNoDiagnostics() => + Collector.Diagnostics.Should().BeEmpty(); + + [Fact] + public void LoadsEntry() => + Block!.LoadedBundles.Should().ContainSingle(b => b.Entries.Count == 1); +}