From 5452afbaed593e8c7120ce09ad546761cf8579c0 Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 17 Jul 2026 19:11:18 -0500 Subject: [PATCH 1/3] Add changelog bundle option for --files --- docs/cli-schema.json | 36 +- docs/cli/changelog/cmd-bundle.md | 51 ++- docs/cli/changelog/cmd-remove.md | 7 +- docs/contribute/bundle-changelogs.md | 36 +- docs/contribute/configure-changelogs-ref.md | 4 +- .../Bundling/ChangelogBundlingService.cs | 86 ++++- .../Bundling/ChangelogRemoveService.cs | 58 ++- .../Bundling/FileFilterLoader.cs | 180 +++++++++ .../Bundling/ProfileFilterResolver.cs | 116 ++++-- .../docs-builder/Commands/ChangelogCommand.cs | 44 ++- .../Changelogs/BundleFilesFilterTests.cs | 349 ++++++++++++++++++ .../Changelogs/ChangelogRemoveTests.cs | 57 +++ 12 files changed, 933 insertions(+), 91 deletions(-) create mode 100644 src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs create mode 100644 tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 6b1b3557ca..eff80b746d 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -3030,7 +3030,7 @@ ], "name": "bundle", "summary": "Aggregate changelog entries matching a filter into a single bundle YAML.", - "notes": "Profile-based commands (bundle \u003Cprofile\u003E \u003Cversion|report\u003E [report] [--plan]): filters, paths, repo metadata,\nresolve, description, hide-features, and release-date behaviour come from changelog.yml. Only --plan is supported\nalongside profile positional arguments; other flags documented below as unsupported in profile-based commands must be set in\nconfiguration instead. Config is auto-discovered from ./changelog.yml or ./docs/changelog.yml. Use\nbundle.release_dates or bundle.profiles.\u003Cname\u003E.release_dates to control auto-population;\n--release-date and --no-release-date require option-based mode.\n\nOption-based mode (no profile argument): exactly one filter must be specified \u2014\n--all, --input-products, --prs, --issues, --release-version, or --report.", + "notes": "Profile-based commands (bundle \u003Cprofile\u003E \u003Cversion|report\u003E [report] [--plan]): filters, paths, repo metadata,\nresolve, description, hide-features, and release-date behaviour come from changelog.yml. Only --plan is supported\nalongside profile positional arguments; other flags documented below as unsupported in profile-based commands must be set in\nconfiguration instead. Config is auto-discovered from ./changelog.yml or ./docs/changelog.yml. Use\nbundle.release_dates or bundle.profiles.\u003Cname\u003E.release_dates to control auto-population;\n--release-date and --no-release-date require option-based mode.\n\nOption-based mode (no profile argument): exactly one filter must be specified \u2014\n--all, --input-products, --prs, --issues, --release-version, --report, or --files.", "usage": "docs-builder changelog bundle [\u003Cprofile\u003E] [\u003Cprofile-arg\u003E] [\u003Cprofile-report\u003E] [options]", "examples": [], "parameters": [ @@ -3053,7 +3053,7 @@ "name": "profile-report", "type": "string", "required": false, - "summary": "Promotion report or URL list file when also providing a version. When provided, the second argument must be a version string and this is the PR/issue filter source (for example, \u0022bundle serverless-release 2026-02 ./report.html\u0022). Optional third positional argument in profile-based commands." + "summary": "Promotion report, URL list file, or changelog path list file when also providing a version. When provided, the second argument must be a version string and this is the filter source (for example, \u0022bundle serverless-release 2026-02 ./report.html\u0022). Optional third positional argument in profile-based commands." }, { "role": "flag", @@ -3158,6 +3158,23 @@ "repeatable": true, "elementType": "string" }, + { + "role": "flag", + "name": "files", + "type": "array", + "required": false, + "summary": "Filter by changelog YAML paths (comma-separated), or a path to a newline-delimited file containing changelog paths. Can be specified multiple times. Forces local entry sourcing. This option is not supported in profile-based commands; pass a path list file as the second or third positional argument instead.", + "repeatable": true, + "elementType": "string" + }, + { + "role": "flag", + "name": "force-local", + "type": "boolean", + "required": false, + "summary": "Force local entry sourcing for this run (equivalent to bundle.use_local_changelogs: true without editing config). Allowed in profile-based commands.", + "defaultValue": "false" + }, { "role": "flag", "name": "owner", @@ -4007,14 +4024,14 @@ "name": "profile-report", "type": "string", "required": false, - "summary": "Optional: Promotion report or URL list file when also providing a version. When provided, the second argument must be a version string and this is the PR/issue filter source." + "summary": "Optional: Promotion report, URL list file, or changelog path list file when also providing a version. When provided, the second argument must be a version string and this is the filter source." }, { "role": "flag", "name": "all", "type": "boolean", "required": false, - "summary": "Remove all changelogs in the directory. Exactly one filter option must be specified: --all, --products, --prs, --issues, or --report.", + "summary": "Remove all changelogs in the directory. Exactly one filter option must be specified: --all, --products, --prs, --issues, --report, or --files.", "defaultValue": "false" }, { @@ -4088,6 +4105,15 @@ "repeatable": true, "elementType": "string" }, + { + "role": "flag", + "name": "files", + "type": "array", + "required": false, + "summary": "Filter by changelog YAML paths (comma-separated), or a path to a newline-delimited file containing changelog paths. Can be specified multiple times. Not supported in profile-based commands; pass a path list file as a positional argument instead.", + "repeatable": true, + "elementType": "string" + }, { "role": "flag", "name": "owner", @@ -4130,7 +4156,7 @@ "name": "report", "type": "string", "required": false, - "summary": "Optional (option-based mode only): URL or file path to a promotion report. Extracts PR URLs and uses them as the filter. Mutually exclusive with --all, --products, --prs, --release-version, and --issues." + "summary": "Optional (option-based mode only): URL or file path to a promotion report. Extracts PR URLs and uses them as the filter. Mutually exclusive with --all, --products, --prs, --release-version, --issues, and --files." }, { "role": "flag", diff --git a/docs/cli/changelog/cmd-bundle.md b/docs/cli/changelog/cmd-bundle.md index 19f72a3253..79a1c28d72 100644 --- a/docs/cli/changelog/cmd-bundle.md +++ b/docs/cli/changelog/cmd-bundle.md @@ -17,9 +17,10 @@ docs-builder changelog bundle elasticsearch-release 9.2.0 ./promotion-report.htm The second positional argument accepts: - A version string (e.g. `9.2.0`, `9.2.0-beta.1`) — lifecycle is inferred automatically (`ga`, `beta`, `rc`) - A promotion report URL or file path -- A plain-text URL list file (one fully-qualified GitHub URL per line) +- A plain-text URL list file (one fully-qualified GitHub PR or issue URL per line) +- A plain-text path list file (one changelog YAML path per line, ending in `.yaml` or `.yml`) -When your profile uses `{version}` in its output pattern and you also want to filter by a report, pass both arguments. +When your profile uses `{version}` in its output pattern and you also want to filter by a report or list file, pass both arguments (version first, then the filter file). Example profile in `changelog.yml`: @@ -48,6 +49,9 @@ Exactly one of the following filter flags is required: - `--issues` — filter by issue URLs or a newline-delimited file of issue URLs - `--release-version` — fetch PR references from a GitHub release tag (e.g. `v9.2.0` or `latest`) - `--report` — filter by PRs referenced in a promotion report (URL or local file) +- `--files` — include specific changelog YAML paths, or a newline-delimited path list file + +`--force-local` is not a filter. It forces local entry sourcing for the run (equivalent to `bundle.use_local_changelogs: true` without editing config) and is allowed in both option-based and profile-based modes. ```sh # Bundle all changelogs in docs/changelog/ @@ -63,6 +67,12 @@ docs-builder changelog bundle \ --release-version v9.2.0 \ --repo elasticsearch \ --owner elastic + +# Bundle an explicit list of changelog files +docs-builder changelog bundle \ + --files "./docs/changelog/a.yaml,./docs/changelog/b.yaml" \ + --output docs/releases/serverless/2026-07-07.yaml \ + --output-products "cloud-serverless 2026-07-07" ``` ## Resolved vs. reference bundles @@ -317,6 +327,43 @@ docs-builder changelog bundle \ By default all changelogs that match PRs in the promotion report are included in the bundle. To apply additional filtering by the changelog type, areas, or products, add [rules.bundle](/contribute/configure-changelogs-ref.md#rules-bundle) configuration settings. +### Bundle by file paths [changelog-bundle-files] + +Use `--files` when you know the exact changelog files to include and they may not have `prs` or `issues` metadata. + +```sh +docs-builder changelog bundle \ + --files "./docs/changelog/a.yaml,./docs/changelog/b.yaml" \ + --output docs/releases/serverless/2026-07-07.yaml \ + --output-products "cloud-serverless 2026-07-07" +``` + +You can also pass a newline-delimited path list file: + +```sh +docs-builder changelog bundle --files ./docs/temp/changelog_files.txt --output ... +``` + +In profile mode, pass the same path list as a positional argument: + +```sh +docs-builder changelog bundle serverless-release 2026-07-07 ./docs/temp/changelog_files.txt +``` + +`--files` / path-list selection always reads the named files from disk (local entry sourcing). It does not fetch entries from the CDN. `rules.bundle` still applies after selection. + +### Force local entry sourcing [changelog-bundle-force-local] + +When a repository defaults to CDN entry sourcing, you can use `--force-local` to read changelog YAML files from the local folder. +This option overrides the `bundle.use_local_changelogs` setting in your `changelog.yml` and is useful for ad hoc bundles that include freshly authored local files that are not on the CDN yet. + +```sh +docs-builder changelog bundle serverless-release 2026-07-07 ./docs/temp/prs.txt --force-local +``` + +`--force-local` is allowed in both option-based and profile-based commands. +Path-list / `--files` filters already force local sourcing, so `--force-local` is optional in that case. + ### Hide features [changelog-bundle-hide-features] You can use the `--hide-features` option to embed feature IDs that should be hidden when the bundle is rendered. This is useful for features that are not yet ready for public documentation. diff --git a/docs/cli/changelog/cmd-remove.md b/docs/cli/changelog/cmd-remove.md index 6486aa3995..d012c2d87f 100644 --- a/docs/cli/changelog/cmd-remove.md +++ b/docs/cli/changelog/cmd-remove.md @@ -5,7 +5,7 @@ Remove changelog YAML files from a directory. Two mutually exclusive modes are available: - **Profile-based**: `docs-builder changelog remove ` — uses the same `bundle.profiles` configuration as [`changelog bundle`](/cli/changelog/bundle.md) to determine which changelogs to remove. -- **Option-based**: `docs-builder changelog remove --products "..."` (or `--prs`, `--issues`, `--all`, `--release-version`, `--report`) — specify the filter directly. +- **Option-based**: `docs-builder changelog remove --products "..."` (or `--prs`, `--issues`, `--all`, `--release-version`, `--report`, `--files`) — specify the filter directly. Before deleting anything, the command checks whether any matching files are referenced by unresolved bundles, to prevent silently breaking the `{changelog}` directive. @@ -46,7 +46,7 @@ Setting `bundle.directory` and `bundle.output_directory` in `changelog.yml` is r ## Option-based examples -Exactly one filter must be specified: `--all`, `--products`, `--prs`, `--issues`, `--release-version`, or `--report`. +Exactly one filter must be specified: `--all`, `--products`, `--prs`, `--issues`, `--release-version`, `--report`, or `--files`. ```sh # Preview what would be removed (dry run) @@ -59,6 +59,9 @@ docs-builder changelog remove \ # Preview using the latest release docs-builder changelog remove --release-version latest --dry-run + +# Remove an explicit list of changelog files +docs-builder changelog remove --files "./docs/changelog/a.yaml,./docs/changelog/b.yaml" --dry-run ``` :::{note} diff --git a/docs/contribute/bundle-changelogs.md b/docs/contribute/bundle-changelogs.md index e10eeff989..5802a2c788 100644 --- a/docs/contribute/bundle-changelogs.md +++ b/docs/contribute/bundle-changelogs.md @@ -24,8 +24,10 @@ The source of truth can be: - automated release notes for GitHub releases - all changelog files that exist in a specific folder - all changelog files that match specific products, versions, and lifecycles +- an explicit list of changelog file paths Deriving the source of truth from the contents of a folder or from the metadata in changelogs are the least accurate options (unless you have additional processes to confirm the validity of that information). +An explicit path list is appropriate for ad hoc releases where a small set of known changelog files must be bundled and those entries may not have `prs` or `issues` fields. It is recommended to use lists that are generated as part of your release coordination activities. Consider your options carefully and discuss with your docs team if necessary. @@ -54,13 +56,14 @@ It is strongly recommended to set `output_products` in your profile so your bund For the most up-to-date changelog configuration options, refer to [changelog.example.yml](https://github.com/elastic/docs-builder/blob/main/config/changelog.example.yml) and [](/contribute/configure-changelogs-ref.md). -### Bundle by report or URL list [profile-url] +### Bundle by report, URL list, or path list [profile-url] If the source of truth for what was shipped in each release is: - a list of GitHub pull requests - a list of GitHub issues - a buildkite promotion report (which contains a list of PRs) +- an explicit list of changelog YAML paths (one `.yaml`/`.yml` path per line) ... your profile does not have any mandatory settings. However it's a good idea to define the [basic bundle settings](/contribute/configure-changelogs-ref.md#bundle-basic) and the [profile settings](/contribute/configure-changelogs-ref.md#bundle-profiles) for the output filename and output products. @@ -164,11 +167,12 @@ The `products` field determines which changelog files are gathered for considera If you created profiles, you can use them with the `changelog bundle` command like this: ```sh -docs-builder changelog bundle +docs-builder changelog bundle ``` -The second argument accepts a version string, a promotion report URL or path, or a URL list file (a plain-text file with one fully-qualified GitHub URL per line). -If you are using a `{version}` placeholder in the `output_products` or `output` fields, you must provide that value as well as your report or URL argument. +The second argument accepts a version string, a promotion report URL or path, a URL list file (a plain-text file with one fully-qualified GitHub URL per line), or path list file (one changelog file path per line). + +If you are using a `{version}` placeholder in the `output_products` or `output` fields, you must provide that value as well as your report or list argument. For example, if the source of truth for what was shipped in each release is: @@ -193,6 +197,20 @@ For example, if the source of truth for what was shipped in each release is: docs-builder changelog bundle serverless-report 2026-02-13 ./promotion-report.html ``` +- a list of changelog files: + + ```sh + # Bundle changelogs from a file list ({version} → "2026-07-17") + docs-builder changelog bundle serverless-release 2026-07-17 ./changelogs.txt + ``` + + ... where `changelogs.txt` is a newline delimited file with paths like this this: + + ```txt + docs/changelog/1770424335-adhoc-security-fix.yaml + docs/changelog/1770424401-adhoc-feature.yaml + ``` + - automated release notes for GitHub releases: ```sh @@ -231,7 +249,7 @@ For example, if the source of truth for what was shipped in each release is: By default all changelogs that match the chosen source of truth are included in the bundle. :::{tip} -It is strongly recommended to pull all of the content from each changelog into the bundle; otherwise you can't move or remove your changelogs. If your bundle contains only references to the files, add set [bundle.resolve](/contribute/configure-changelogs-ref.md#bundle-basic) to true and re-generate your bundle. +It is strongly recommended to pull all of the content from each changelog into the bundle; otherwise you can't move or remove your changelogs. If your bundle contains only references to the files, set [bundle.resolve](/contribute/configure-changelogs-ref.md#bundle-basic) to true and re-generate your bundle. ::: To apply additional filtering by the changelog type, areas, or products, add [bundle rules](#rules). @@ -285,7 +303,7 @@ You can use the `docs-builder changelog remove` command to remove changelogs. If you created profiles, you can use them like this: ```sh -docs-builder changelog remove +docs-builder changelog remove ``` For example, if the source of truth for what was shipped in each release is: @@ -302,6 +320,12 @@ For example, if the source of truth for what was shipped in each release is: docs-builder changelog remove serverless-report ./promotion-report.html ``` +- a list of changelog files: + + ```sh + docs-builder changelog remove serverless-release ./changelogs.txt + ``` + - automated release notes for GitHub releases: ```sh diff --git a/docs/contribute/configure-changelogs-ref.md b/docs/contribute/configure-changelogs-ref.md index e2c6015d48..bdb7a6e489 100644 --- a/docs/contribute/configure-changelogs-ref.md +++ b/docs/contribute/configure-changelogs-ref.md @@ -73,9 +73,11 @@ The authoring repo is resolved with the same precedence as `changelog upload`: ` Sourcing is decided per run: -- **Local folder.** Used when `bundle.use_local_changelogs: true`, when `--directory` is passed, or when the authoring repo cannot be resolved. The folder must contain the changelog files. +- **Local folder.** Used when `bundle.use_local_changelogs: true`, when `--force-local` is passed, when `--files` / a path-list filter is used, when `--directory` is passed, or when the authoring repo cannot be resolved. The folder must contain the changelog files. - **CDN (default when a repo resolves).** Used when the authoring repo resolves, local sourcing is not forced, and a CDN base URL is configured (`DOCS_BUILDER_CHANGELOG_CDN`, defaulting to the public distribution). The command fetches `changelog/{org}/{repo}/{branch}/registry.json` and the entries it lists, then applies the bundle's own product/PR/issue filters to the downloaded set. +Use `--force-local` for uncommon ad hoc runs that need the local folder without editing `changelog.yml`. Path-list / `--files` filters always force local sourcing because they select files by path on disk. + Because entries are org/repo/branch-scoped, one repository can produce a bundle for a shared product (for example, `cloud-serverless`) while sourcing its own entries from `changelog/{org}/{repo}/{branch}/`, without that product appearing in the repository's `docset.yml`. The `{changelog}` directive's `:cdn:` mode still consumes product-scoped *bundles*, so a repository that also renders its own release notes declares each product under `release_notes` as before. ### Bundle descriptions [bundle-descriptions] diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 0dd5ad830c..70b0f9a31c 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -42,6 +42,19 @@ public record BundleChangelogsArguments public bool? Resolve { get; init; } public string[]? Prs { get; init; } public string[]? Issues { get; init; } + + /// + /// Explicit changelog YAML paths (or a path-list file) for the --files filter. + /// Mutually exclusive with other filter sources. Forces local entry sourcing. + /// + public string[]? Files { get; init; } + + /// + /// When true, force local entry sourcing for this run (CLI --force-local), + /// equivalent to bundle.use_local_changelogs: true without editing config. + /// + public bool ForceLocal { get; init; } + public string? Owner { get; init; } public string? Repo { get; init; } @@ -223,9 +236,11 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle // an org/repo/branch pool (changelog/{org}/{repo}/{branch}/...), so CDN sourcing keys off the // resolvable authoring repo (bundle.repo / --repo), with org and branch defaulting when unset — // not the bundle's target products. Fall back to the local folder when the user forces it - // (bundle.use_local_changelogs / --directory), the repo is unresolvable, or no CDN base is - // configured. This stays in lockstep with PlanBundleAsync's needs_network decision. - var useLocalChangelogs = config?.Bundle?.UseLocalChangelogs ?? false; + // (bundle.use_local_changelogs / --force-local / --files / --directory), the repo is unresolvable, + // or no CDN base is configured. This stays in lockstep with PlanBundleAsync's needs_network decision. + var useLocalChangelogs = (config?.Bundle?.UseLocalChangelogs ?? false) + || input.ForceLocal + || input.Files is { Length: > 0 }; var authoringRepo = ChangelogRepoOwnerResolver.NormalizeRepo(input.Repo); var authoringOwner = ChangelogRepoOwnerResolver.ResolveOwner(input.Owner, input.Repo, DefaultOwner); var authoringBranch = string.IsNullOrWhiteSpace(input.Branch) ? DefaultBranch : input.Branch; @@ -242,11 +257,20 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle if (!ValidateLinkAllowlist(collector, input)) return false; - // Load PR or issue filter values + // Load PR, issue, or file filter values var prsToMatch = new HashSet(StringComparer.OrdinalIgnoreCase); var issuesToMatch = new HashSet(StringComparer.OrdinalIgnoreCase); + IReadOnlyList? explicitFilePaths = null; - if (input.Prs is { Length: > 0 }) + if (input.Files is { Length: > 0 }) + { + var fileFilterLoader = new FileFilterLoader(_fileSystem); + var fileFilterResult = await fileFilterLoader.LoadFilesAsync(collector, input.Files, input.Directory, ctx); + if (!fileFilterResult.IsValid) + return false; + explicitFilePaths = fileFilterResult.FilePaths; + } + else if (input.Prs is { Length: > 0 }) { var prFilterLoader = new PrFilterLoader(_fileSystem); var prFilterResult = await prFilterLoader.LoadPrsAsync(collector, input.Prs, input.Owner, input.Repo, ctx); @@ -273,9 +297,16 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle var filterCriteria = BuildFilterCriteria(input, prsToMatch, issuesToMatch); // Source and match changelog entries — from the CDN (default) or the local folder. + // Explicit --files / path-list selection always loads the named local paths (IncludeAll). var entryMatcher = new ChangelogEntryMatcher(_fileSystem, ReleaseNotesSerialization.GetEntryDeserializer(), _logger); ChangelogMatchResult matchResult; - if (useCdn) + if (explicitFilePaths != null) + { + _logger.LogInformation("Matching {Count} explicitly selected changelog files", explicitFilePaths.Count); + var filesCriteria = filterCriteria with { IncludeAll = true }; + matchResult = await entryMatcher.MatchChangelogsAsync(collector, explicitFilePaths, filesCriteria, ctx); + } + else if (useCdn) { var contents = await FetchCdnEntriesAsync(collector, authoringOwner, authoringRepo, authoringBranch, ctx); if (contents == null) @@ -563,6 +594,7 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle InputProducts = filterResult.Products, Prs = filterResult.Prs, Issues = filterResult.Issues, + Files = filterResult.Files, All = false, Output = outputPath, OutputProducts = outputProducts, @@ -664,7 +696,10 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments // CDN entry sourcing needs network access for the Docker bundle run. Mirror the run-mode gate: // active when the authoring repo resolves (profile/config bundle.repo), the user has not forced // local sourcing, and a CDN base is configured. - var useLocalChangelogs = config?.Bundle?.UseLocalChangelogs ?? false; + var useLocalChangelogs = (config?.Bundle?.UseLocalChangelogs ?? false) + || input.ForceLocal + || input.Files is { Length: > 0 } + || await ProfileFilterForcesLocalAsync(input, ctx); var explicitDirectory = !string.IsNullOrWhiteSpace(input.Directory); var authoringRepo = ChangelogRepoOwnerResolver.NormalizeRepo(input.Repo ?? profileDef?.Repo ?? config?.Bundle?.Repo); if (ShouldSourceFromCdn(authoringRepo, useLocalChangelogs: useLocalChangelogs, explicitDirectory: explicitDirectory)) @@ -802,7 +837,7 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments return byName.Select(kv => (kv.Key, kv.Value)).ToList(); } - /// Gate for repo-scoped CDN entry sourcing: true when the authoring repo resolves, local sourcing is not forced (bundle.use_local_changelogs/--directory), and a CDN base is configured. + /// Gate for repo-scoped CDN entry sourcing: true when the authoring repo resolves, local sourcing is not forced (bundle.use_local_changelogs/--force-local/--files/--directory), and a CDN base is configured. private static bool ShouldSourceFromCdn(string? authoringRepo, bool useLocalChangelogs, bool explicitDirectory) { if (useLocalChangelogs || explicitDirectory || string.IsNullOrWhiteSpace(authoringRepo)) @@ -810,6 +845,33 @@ private static bool ShouldSourceFromCdn(string? authoringRepo, bool useLocalChan return ChangelogCdn.ResolveBaseUri() is not null; } + /// + /// Detects whether a profile positional list file is a changelog path list (which forces local sourcing). + /// Used by so needs_network matches run-mode without emitting filter diagnostics. + /// + private async Task ProfileFilterForcesLocalAsync(BundleChangelogsArguments input, Cancel ctx) + { + if (string.IsNullOrWhiteSpace(input.Profile)) + return false; + + var listPath = !string.IsNullOrWhiteSpace(input.ProfileReport) + ? input.ProfileReport + : input.ProfileArgument; + if (string.IsNullOrWhiteSpace(listPath) || !_fileSystem.File.Exists(listPath)) + return false; + + if (_fileSystem.Path.GetExtension(listPath).ToLowerInvariant() is ".html" or ".htm") + return false; + + var content = await _fileSystem.File.ReadAllTextAsync(listPath, ctx); + var lines = content + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(l => !string.IsNullOrWhiteSpace(l)) + .ToArray(); + + return lines.Length > 0 && lines.All(FileFilterLoader.IsYamlExtension); + } + private bool ValidateInput(IDiagnosticsCollector collector, BundleChangelogsArguments input, bool requireDirectoryExists) { if (string.IsNullOrWhiteSpace(input.Directory)) @@ -824,7 +886,7 @@ private bool ValidateInput(IDiagnosticsCollector collector, BundleChangelogsArgu return false; } - // Validate filter options - exactly one of: --all, --input-products, --prs, --issues + // Validate filter options - exactly one of: --all, --input-products, --prs, --issues, --files var specifiedFilters = new List(); if (input.All) specifiedFilters.Add("--all"); @@ -834,17 +896,19 @@ private bool ValidateInput(IDiagnosticsCollector collector, BundleChangelogsArgu specifiedFilters.Add("--prs"); if (input.Issues is { Length: > 0 }) specifiedFilters.Add("--issues"); + if (input.Files is { Length: > 0 }) + specifiedFilters.Add("--files"); if (specifiedFilters.Count == 0) { - collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --input-products, --prs, or --issues"); + collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --input-products, --prs, --issues, or --files"); return false; } if (specifiedFilters.Count > 1) { collector.EmitError(string.Empty, - $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specifiedFilters)}. Please use only one filter option: --all, --input-products, --prs, or --issues"); + $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specifiedFilters)}. Please use only one filter option: --all, --input-products, --prs, --issues, or --files"); return false; } diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs index 8d311920d6..a5162e75db 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs @@ -26,6 +26,13 @@ public record ChangelogRemoveArguments public IReadOnlyList? Products { get; init; } public string[]? Prs { get; init; } public string[]? Issues { get; init; } + + /// + /// Explicit changelog YAML paths (or a path-list file) for the --files filter. + /// Mutually exclusive with other filter sources. + /// + public string[]? Files { get; init; } + public string? Owner { get; init; } public string? Repo { get; init; } public bool DryRun { get; init; } @@ -122,6 +129,7 @@ public async Task RemoveChangelogs(IDiagnosticsCollector collector, Change Products = filterResult.Products, Prs = filterResult.Prs, Issues = filterResult.Issues, + Files = filterResult.Files, All = false }; } @@ -142,8 +150,17 @@ public async Task RemoveChangelogs(IDiagnosticsCollector collector, Change var prsToMatch = new HashSet(StringComparer.OrdinalIgnoreCase); var issuesToMatch = new HashSet(StringComparer.OrdinalIgnoreCase); + IReadOnlyList? explicitFilePaths = null; - if (input.Prs is { Length: > 0 }) + if (input.Files is { Length: > 0 }) + { + var loader = new FileFilterLoader(_fileSystem); + var result = await loader.LoadFilesAsync(collector, input.Files, input.Directory, ctx); + if (!result.IsValid) + return false; + explicitFilePaths = result.FilePaths; + } + else if (input.Prs is { Length: > 0 }) { var loader = new PrFilterLoader(_fileSystem); var result = await loader.LoadPrsAsync(collector, input.Prs, input.Owner, input.Repo, ctx); @@ -160,21 +177,30 @@ public async Task RemoveChangelogs(IDiagnosticsCollector collector, Change issuesToMatch = result.IssuesToMatch; } - // A placeholder output path is passed to discovery so the bundle file itself is excluded. - // Directory is non-null here: ApplyConfigDefaults ensures a value and ValidateInput enforces non-empty. - var placeholderOutput = _fileSystem.Path.Join(input.Directory!, "changelog-bundle.yaml"); - var fileDiscovery = new ChangelogFileDiscovery(_fileSystem, _logger); - var yamlFiles = await fileDiscovery.DiscoverChangelogFilesAsync(input.Directory!, placeholderOutput, ctx); - - if (yamlFiles.Count == 0) + var filterCriteria = BuildFilterCriteria(input, prsToMatch, issuesToMatch); + var entryMatcher = new ChangelogEntryMatcher(_fileSystem, ReleaseNotesSerialization.GetEntryDeserializer(), _logger); + ChangelogMatchResult matchResult; + if (explicitFilePaths != null) { - collector.EmitError(input.Directory!, "No changelog YAML files found in directory"); - return false; + var filesCriteria = filterCriteria with { IncludeAll = true }; + matchResult = await entryMatcher.MatchChangelogsAsync(collector, explicitFilePaths, filesCriteria, ctx); } + else + { + // A placeholder output path is passed to discovery so the bundle file itself is excluded. + // Directory is non-null here: ApplyConfigDefaults ensures a value and ValidateInput enforces non-empty. + var placeholderOutput = _fileSystem.Path.Join(input.Directory!, "changelog-bundle.yaml"); + var fileDiscovery = new ChangelogFileDiscovery(_fileSystem, _logger); + var yamlFiles = await fileDiscovery.DiscoverChangelogFilesAsync(input.Directory!, placeholderOutput, ctx); - var filterCriteria = BuildFilterCriteria(input, prsToMatch, issuesToMatch); - var entryMatcher = new ChangelogEntryMatcher(_fileSystem, ReleaseNotesSerialization.GetEntryDeserializer(), _logger); - var matchResult = await entryMatcher.MatchChangelogsAsync(collector, yamlFiles, filterCriteria, ctx); + if (yamlFiles.Count == 0) + { + collector.EmitError(input.Directory!, "No changelog YAML files found in directory"); + return false; + } + + matchResult = await entryMatcher.MatchChangelogsAsync(collector, yamlFiles, filterCriteria, ctx); + } if (matchResult.Entries.Count == 0) { @@ -274,17 +300,19 @@ private bool ValidateInput(IDiagnosticsCollector collector, ChangelogRemoveArgum specified.Add("--prs"); if (input.Issues is { Length: > 0 }) specified.Add("--issues"); + if (input.Files is { Length: > 0 }) + specified.Add("--files"); if (specified.Count == 0) { - collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --products, --prs, or --issues"); + collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --products, --prs, --issues, or --files"); 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"); + $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specified)}. Please use only one filter option: --all, --products, --prs, --issues, or --files"); return false; } diff --git a/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs b/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs new file mode 100644 index 0000000000..64182cdee9 --- /dev/null +++ b/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs @@ -0,0 +1,180 @@ +// 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 Elastic.Documentation.Diagnostics; + +namespace Elastic.Changelog.Bundling; + +/// +/// Loads changelog file paths for the --files / path-list filter. +/// Values may be changelog YAML paths or a newline-delimited path-list file. +/// +public class FileFilterLoader(IFileSystem fileSystem) +{ + /// + /// Resolves into changelog YAML paths that exist on disk. + /// + /// + /// Optional directory used to resolve basename-only or relative paths that are not found from the current directory. + /// + public async Task LoadFilesAsync( + IDiagnosticsCollector collector, + string[]? files, + string? baseDirectory, + Cancel ctx) + { + var resolved = new List(); + + if (files is not { Length: > 0 }) + return new FileFilterResult { IsValid = true, FilePaths = resolved }; + + foreach (var value in files) + { + if (string.IsNullOrWhiteSpace(value)) + continue; + + if (IsPathListFile(value)) + { + if (!await ReadPathListFileAsync(collector, value, baseDirectory, resolved, ctx)) + return new FileFilterResult { IsValid = false, FilePaths = resolved }; + continue; + } + + var path = ResolveChangelogPath(value, baseDirectory); + if (path == null) + { + EmitMissingFileError(collector, value, "--files"); + return new FileFilterResult { IsValid = false, FilePaths = resolved }; + } + + if (!IsYamlExtension(path)) + { + collector.EmitError(path, $"--files values must be changelog YAML paths (.yaml/.yml) or a newline-delimited path list file. Found: {value}"); + return new FileFilterResult { IsValid = false, FilePaths = resolved }; + } + + resolved.Add(path); + } + + if (resolved.Count == 0) + { + collector.EmitError(string.Empty, "No changelog file paths were resolved from --files"); + return new FileFilterResult { IsValid = false, FilePaths = resolved }; + } + + return new FileFilterResult { IsValid = true, FilePaths = resolved }; + } + + /// + /// Reads a newline-delimited path list and appends resolved changelog paths to . + /// + public async Task ReadPathListFileAsync( + IDiagnosticsCollector collector, + string listFilePath, + string? baseDirectory, + List resolved, + Cancel ctx) + { + var content = await fileSystem.File.ReadAllTextAsync(listFilePath, ctx); + var lines = content + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(l => !string.IsNullOrWhiteSpace(l)) + .ToArray(); + + if (lines.Length == 0) + { + collector.EmitError(listFilePath, "Path list file is empty"); + return false; + } + + foreach (var line in lines) + { + if (LooksLikeHttpUrl(line)) + { + collector.EmitError( + listFilePath, + $"Path list file must contain changelog YAML paths (.yaml/.yml), not URLs. Found: {line}" + ); + return false; + } + + if (!IsYamlExtension(line)) + { + collector.EmitError( + listFilePath, + $"Path list file must contain changelog YAML paths (.yaml/.yml). Found: {line}" + ); + return false; + } + + var path = ResolveChangelogPath(line, baseDirectory); + if (path == null) + { + EmitMissingFileError(collector, line, "--files"); + return false; + } + + resolved.Add(path); + } + + return true; + } + + private bool IsPathListFile(string value) + { + if (!fileSystem.File.Exists(value)) + return false; + + // An existing .yaml/.yml file is a changelog entry, not a path list. + if (IsYamlExtension(value)) + return false; + + return true; + } + + private string? ResolveChangelogPath(string value, string? baseDirectory) + { + if (fileSystem.File.Exists(value)) + return value; + + if (!string.IsNullOrWhiteSpace(baseDirectory)) + { + var joined = fileSystem.Path.Join(baseDirectory, value); + if (fileSystem.File.Exists(joined)) + return joined; + } + + return null; + } + + private void EmitMissingFileError(IDiagnosticsCollector collector, string file, string optionName) + { + var currentDir = fileSystem.Directory.GetCurrentDirectory(); + collector.EmitError( + file, + $"File does not exist. Current directory: {currentDir}. " + + $"Tip: Repeat {optionName} for each file, or use comma-separated values (e.g., {optionName} \"file1.yaml,file2.yaml\"). " + + "Paths support tilde (~) expansion and can be relative or absolute." + ); + } + + internal static bool IsYamlExtension(string path) + { + var ext = System.IO.Path.GetExtension(path); + return ext.Equals(".yaml", StringComparison.OrdinalIgnoreCase) + || ext.Equals(".yml", StringComparison.OrdinalIgnoreCase); + } + + private static bool LooksLikeHttpUrl(string value) => + value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase); +} + +/// Result of loading file-path filter values. +public record FileFilterResult +{ + public required bool IsValid { get; init; } + public required IReadOnlyList FilePaths { get; init; } +} diff --git a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs index a8b39073ed..eaf9b4d600 100644 --- a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs +++ b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs @@ -16,8 +16,8 @@ namespace Elastic.Changelog.Bundling; /// -/// The resolved filter derived from a bundle profile — either a product list, a set of PR URLs, or a set of issue URLs. -/// Exactly one of , and will be non-null on a successful result. +/// The resolved filter derived from a bundle profile — a product list, PR URLs, issue URLs, or changelog file paths. +/// Exactly one of , , , and will be non-null on a successful result. /// public record ProfileFilterResult { @@ -30,10 +30,13 @@ public record ProfileFilterResult /// Issue URLs extracted from a URL list file supplied as the profile argument. public string[]? Issues { get; init; } + /// Changelog YAML paths extracted from a path list file supplied as the profile argument. + public string[]? Files { get; init; } + /// /// The resolved version string used for placeholder substitution. /// This is the profile argument itself for version-based invocations, or "unknown" when - /// a promotion report or URL list file was parsed (because the actual version is not available from the file). + /// a promotion report or URL/path list file was parsed (because the actual version is not available from the file). /// When both a version and a report are provided (Phase 3.4), this is always the explicit version. /// public string Version { get; init; } = "unknown"; @@ -52,8 +55,8 @@ public record ProfileFilterResult /// report URL/path, or URL list file) into a concrete filter that can be used by both /// and . /// -/// Result of resolving a URL list file into PR or issue URLs. -internal record UrlListFileResult(string[]? Prs, string[]? Issues); +/// Result of resolving a newline-delimited list file into PR URLs, issue URLs, or changelog paths. +internal record ListFileResult(string[]? Prs, string[]? Issues, string[]? Files); public static partial class ProfileFilterResolver { @@ -118,6 +121,7 @@ public static partial class ProfileFilterResolver string version; string[]? prsFromReport = null; string[]? issuesFromFile = null; + string[]? filesFromList = null; switch (argType) { @@ -135,12 +139,13 @@ public static partial class ProfileFilterResolver } case ProfileArgumentType.UrlListFile: { - var result = await ResolveUrlListFileAsync(collector, profileArgument, fileSystem, ctx); + var result = await ResolveListFileAsync(collector, profileArgument, fileSystem, ctx); if (result == null) return null; prsFromReport = result.Prs; issuesFromFile = result.Issues; + filesFromList = result.Files; version = "unknown"; break; } @@ -157,14 +162,17 @@ public static partial class ProfileFilterResolver .Replace("{version}", version) .Replace("{lifecycle}", lifecycle); - // If we have PRs or issues from a file/report, return those directly + // If we have PRs, issues, or file paths from a file/report, return those directly if (prsFromReport != null) return new ProfileFilterResult { Prs = prsFromReport, Version = version }; if (issuesFromFile != null) return new ProfileFilterResult { Issues = issuesFromFile, Version = version }; - // Without a promotion report or URL list we need a products pattern to filter by + if (filesFromList != null) + return new ProfileFilterResult { Files = filesFromList, Version = version }; + + // Without a promotion report or URL/path list we need a products pattern to filter by if (string.IsNullOrWhiteSpace(productsPattern)) { collector.EmitError( @@ -211,13 +219,13 @@ public static partial class ProfileFilterResolver return null; } - // A products pattern is mutually exclusive with a report/URL-list filter + // A products pattern is mutually exclusive with a report/URL/path-list filter if (!string.IsNullOrWhiteSpace(profile.Products)) { collector.EmitError( string.Empty, $"Profile '{profileName}' has a 'products' pattern configured. " + - "A promotion report or URL list file cannot be combined with a products pattern filter." + "A promotion report, URL list file, or path list file cannot be combined with a products pattern filter." ); return null; } @@ -246,29 +254,32 @@ public static partial class ProfileFilterResolver } case ProfileArgumentType.UrlListFile: { - var result = await ResolveUrlListFileAsync(collector, profileReport, fileSystem, ctx); + var result = await ResolveListFileAsync(collector, profileReport, fileSystem, ctx); if (result == null) return null; - return result.Prs != null - ? new ProfileFilterResult { Prs = result.Prs, Version = version } - : new ProfileFilterResult { Issues = result.Issues, Version = version }; + if (result.Prs != null) + return new ProfileFilterResult { Prs = result.Prs, Version = version }; + if (result.Issues != null) + return new ProfileFilterResult { Issues = result.Issues, Version = version }; + return new ProfileFilterResult { Files = result.Files, Version = version }; } default: collector.EmitError( string.Empty, - $"The third argument '{profileReport}' must be a promotion report URL, a local HTML file, or a URL list file. " + - "Use a URL (https://...), a local .html file, or a text file containing fully-qualified GitHub PR/issue URLs." + $"The third argument '{profileReport}' must be a promotion report URL, a local HTML file, a URL list file, or a path list file. " + + "Use a URL (https://...), a local .html file, a text file containing fully-qualified GitHub PR/issue URLs, " + + "or a text file containing changelog YAML paths (.yaml/.yml)." ); return null; } } /// - /// Reads a newline-delimited URL list file and validates/classifies its contents as PR or issue URLs. + /// Reads a newline-delimited list file and classifies its contents as PR URLs, issue URLs, or changelog paths. /// Returns null and emits errors on failure. /// - internal static async Task ResolveUrlListFileAsync( + internal static async Task ResolveListFileAsync( IDiagnosticsCollector collector, string filePath, ScopedFileSystem fileSystem, @@ -282,51 +293,80 @@ public static partial class ProfileFilterResolver if (lines.Length == 0) { - collector.EmitError(filePath, "URL list file is empty"); + collector.EmitError(filePath, "List file is empty"); return null; } var hasPrs = false; var hasIssues = false; + var hasPaths = false; foreach (var line in lines) { - if (!line.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && - !line.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + var isHttp = line.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + line.StartsWith("http://", StringComparison.OrdinalIgnoreCase); + + if (isHttp) { - collector.EmitError( - filePath, - $"File must contain fully-qualified GitHub URLs (e.g. https://github.com/owner/repo/pull/123). " + - $"Numbers and short forms are not allowed. Found: {line}" - ); - return null; + if (GitHubPrUrlRegex().IsMatch(line)) + hasPrs = true; + else if (GitHubIssueUrlRegex().IsMatch(line)) + hasIssues = true; + else + { + collector.EmitError( + filePath, + $"File must contain GitHub pull request or issue URLs " + + $"(e.g. https://github.com/owner/repo/pull/123 or https://github.com/owner/repo/issues/456), " + + $"or changelog YAML paths (.yaml/.yml). Not a recognized URL: {line}" + ); + return null; + } + } + else if (FileFilterLoader.IsYamlExtension(line)) + { + hasPaths = true; } - - if (GitHubPrUrlRegex().IsMatch(line)) - hasPrs = true; - else if (GitHubIssueUrlRegex().IsMatch(line)) - hasIssues = true; else { collector.EmitError( filePath, - $"File must contain GitHub pull request or issue URLs " + - $"(e.g. https://github.com/owner/repo/pull/123 or https://github.com/owner/repo/issues/456). " + - $"Not a recognized URL: {line}" + $"File must contain fully-qualified GitHub URLs (e.g. https://github.com/owner/repo/pull/123) " + + $"or changelog YAML paths (.yaml/.yml). Numbers and short forms are not allowed. Found: {line}" ); return null; } } - if (hasPrs && hasIssues) + var categoryCount = (hasPrs ? 1 : 0) + (hasIssues ? 1 : 0) + (hasPaths ? 1 : 0); + if (categoryCount > 1) { - collector.EmitError(filePath, "File must contain only pull request URLs or only issue URLs, not a mix."); + var message = hasPaths + ? "File must contain only pull request URLs, only issue URLs, or only changelog YAML paths — not a mix." + : "File must contain only pull request URLs or only issue URLs, not a mix."; + collector.EmitError(filePath, message); return null; } - return hasPrs ? new UrlListFileResult(lines, null) : new UrlListFileResult(null, lines); + if (hasPaths) + return new ListFileResult(null, null, lines); + + return hasPrs + ? new ListFileResult(lines, null, null) + : new ListFileResult(null, lines, null); } + /// + /// Reads a newline-delimited URL list file and validates/classifies its contents as PR or issue URLs. + /// Returns null and emits errors on failure. + /// + internal static async Task ResolveUrlListFileAsync( + IDiagnosticsCollector collector, + string filePath, + ScopedFileSystem fileSystem, + Cancel ctx) => + await ResolveListFileAsync(collector, filePath, fileSystem, ctx); + private static ProfileArgumentType DetectLocalFileType(ScopedFileSystem fileSystem, string path) => fileSystem.Path.GetExtension(path).ToLowerInvariant() is ".html" or ".htm" ? ProfileArgumentType.PromotionReportFile diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index d0e28f8599..6e13e997d9 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -550,11 +550,11 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// bundle.release_dates or bundle.profiles.<name>.release_dates to control auto-population; /// --release-date and --no-release-date require option-based mode. /// Option-based mode (no profile argument): exactly one filter must be specified — - /// --all, --input-products, --prs, --issues, --release-version, or --report. + /// --all, --input-products, --prs, --issues, --release-version, --report, or --files. /// /// Profile name from bundle.profiles in config (for example, "elasticsearch-release"). Used as the first positional argument in profile-based commands. The equivalent configuration entry is bundle.profiles.<name>. /// Version number or promotion report URL/path when using a profile (for example, "9.2.0" or "https://buildkite.../promotion-report.html"). Required second positional argument in profile-based commands. - /// Promotion report or URL list file when also providing a version. When provided, the second argument must be a version string and this is the PR/issue filter source (for example, "bundle serverless-release 2026-02 ./report.html"). Optional third positional argument in profile-based commands. + /// Promotion report, URL list file, or changelog path list file when also providing a version. When provided, the second argument must be a version string and this is the filter source (for example, "bundle serverless-release 2026-02 ./report.html"). Optional third positional argument in profile-based commands. /// Include all changelogs in the directory. This option is not supported in profile-based commands. The equivalent configuration option is bundle.profiles.<name>.products: "* * *". /// Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' in option-based mode. This option is not supported in profile-based commands; configuration is auto-discovered. /// Directory containing changelog YAML files. Uses config bundle.directory or defaults to current directory. This option is not supported in profile-based commands. The equivalent configuration option is bundle.directory. @@ -569,6 +569,8 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// GitHub repository owner for PR/issue numbers or --release-version. Falls back to bundle.owner or "elastic". This option is not supported in profile-based commands. The equivalent configuration options are bundle.owner or bundle.profiles.<name>.owner. /// Branch whose CDN changelog entry pool (changelog/{org}/{repo}/{branch}/...) is sourced from. Falls back to bundle.branch or "main". This option is not supported in profile-based commands. The equivalent configuration options are bundle.branch or bundle.profiles.<name>.branch. /// Filter by pull request URLs (comma-separated), or a path to a newline-delimited file containing fully-qualified GitHub PR URLs. Can be specified multiple times. This option is not supported in profile-based commands. Pass a promotion report as the second or third positional argument instead, or set source: github_release on the profile. + /// Filter by changelog YAML paths (comma-separated), or a path to a newline-delimited file containing changelog paths. Can be specified multiple times. Forces local entry sourcing. This option is not supported in profile-based commands; pass a path list file as the second or third positional argument instead. + /// Force local entry sourcing for this run (equivalent to bundle.use_local_changelogs: true without editing config). Allowed in profile-based commands. /// GitHub repository name for PR/issue numbers or --release-version. Falls back to bundle.repo or the product ID. This option is not supported in profile-based commands. The equivalent configuration options are bundle.repo or bundle.profiles.<name>.repo. /// URL or file path to a promotion report; extracts PR URLs as the filter. This option is not supported in profile-based commands. Pass the report as the second or third positional argument instead. /// GitHub release tag to use as a filter source (for example, "v9.2.0" or "latest"). Fetches PR references from release notes. This option is not supported in profile-based commands. The equivalent configuration option is bundle.profiles.<name>.source: github_release. @@ -591,6 +593,8 @@ public async Task Bundle( string? output = null, [ArgumentParser(typeof(ProductInfoParser))] ProductArgumentList? outputProducts = null, string[]? issues = null, + string[]? files = null, + bool forceLocal = false, string? owner = null, string? branch = null, bool plan = false, @@ -612,10 +616,10 @@ public async Task Bundle( // --release-version mode: resolve the release into a PR list and proceed as if --prs was specified if (releaseVersion != null) { - if (all || (inputProducts is { Count: > 0 }) || (prs is { Length: > 0 }) || (issues is { Length: > 0 })) + if (all || (inputProducts is { Count: > 0 }) || (prs is { Length: > 0 }) || (issues is { Length: > 0 }) || (files is { Length: > 0 })) { collector.EmitError(string.Empty, - "--release-version is mutually exclusive with --all, --input-products, --prs, and --issues."); + "--release-version is mutually exclusive with --all, --input-products, --prs, --issues, and --files."); return 1; } @@ -659,6 +663,7 @@ public async Task Bundle( var allPrs = ExpandCommaSeparated(prs); var allIssues = ExpandCommaSeparated(issues); + var allFiles = ExpandCommaSeparated(files); // Validate filter/output options against profile mode if (isProfileMode) @@ -674,6 +679,8 @@ public async Task Bundle( forbidden.Add("--prs"); if (allIssues.Count > 0) forbidden.Add("--issues"); + if (allFiles.Count > 0) + forbidden.Add("--files"); if (!string.IsNullOrWhiteSpace(report)) forbidden.Add("--report"); if (!string.IsNullOrWhiteSpace(output)) @@ -731,12 +738,14 @@ public async Task Bundle( specifiedFilters.Add("--prs"); if (allIssues.Count > 0) specifiedFilters.Add("--issues"); + if (allFiles.Count > 0) + specifiedFilters.Add("--files"); if (!string.IsNullOrWhiteSpace(report)) specifiedFilters.Add("--report"); if (specifiedFilters.Count == 0) { - collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --input-products, --prs, --issues, --report, or use a profile (e.g., 'bundle elasticsearch-release 9.2.0')"); + collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --input-products, --prs, --issues, --report, --files, or use a profile (e.g., 'bundle elasticsearch-release 9.2.0')"); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -745,7 +754,7 @@ public async Task Bundle( if (specifiedFilters.Count > 1) { - collector.EmitError(string.Empty, $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specifiedFilters)}. Please use only one filter option: --all, --input-products, --prs, --issues, or --report"); + collector.EmitError(string.Empty, $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specifiedFilters)}. Please use only one filter option: --all, --input-products, --prs, --issues, --report, or --files"); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -842,6 +851,11 @@ public async Task Bundle( Output = processedOutput, Profile = profile, ProfileArgument = profileArg, + ProfileReport = isProfileMode ? profileReport : null, + Files = allFiles.Count > 0 ? allFiles.ToArray() : null, + ForceLocal = forceLocal, + Directory = directory?.FullName, + Repo = repo, Config = config?.FullName, Description = description }; @@ -902,6 +916,8 @@ public async Task Bundle( Resolve = shouldResolve, Prs = allPrs.Count > 0 ? allPrs.ToArray() : null, Issues = allIssues.Count > 0 ? allIssues.ToArray() : null, + Files = allFiles.Count > 0 ? allFiles.ToArray() : null, + ForceLocal = forceLocal, Owner = owner, Repo = repo, Branch = branch, @@ -930,20 +946,21 @@ async static (s, collector, state, ctx) => await s.BundleChangelogs(collector, s /// /// Optional: Profile name from bundle.profiles in config (for example, "elasticsearch-release"). When specified, the second argument is the version or promotion report URL. /// Optional: Version number or promotion report URL/path when using a profile (for example, "9.2.0" or "https://buildkite.../promotion-report.html") - /// Optional: Promotion report or URL list file when also providing a version. When provided, the second argument must be a version string and this is the PR/issue filter source. - /// Remove all changelogs in the directory. Exactly one filter option must be specified: --all, --products, --prs, --issues, or --report. + /// Optional: Promotion report, URL list file, or changelog path list file when also providing a version. When provided, the second argument must be a version string and this is the filter source. + /// Remove all changelogs in the directory. Exactly one filter option must be specified: --all, --products, --prs, --issues, --report, or --files. /// 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. Valid in both profile and raw mode. /// Proceed with removal even when files are referenced by unresolved bundles. Emits warnings instead of errors for each dependency. Valid in both profile and raw mode. /// Filter by issue URLs (comma-separated) or a path to a newline-delimited file containing fully-qualified GitHub issue URLs. Can be specified multiple times. + /// Filter by changelog YAML paths (comma-separated), or a path to a newline-delimited file containing changelog paths. Can be specified multiple times. Not supported in profile-based commands; pass a path list file as a positional argument instead. /// Optional: GitHub repository owner, which is used when PRs or issues are specified as numbers or when using --release-version. Falls back to bundle.owner in changelog.yml when not specified. If that value is also absent, "elastic" is used. /// Filter by products in format "product target lifecycle, ..." (for example, "elasticsearch 9.3.0 ga"). All three parts are required but can be wildcards (*). /// Filter by pull request URLs (comma-separated) or a path to a newline-delimited file containing fully-qualified GitHub PR URLs. Can be specified multiple times. /// GitHub release tag to use as a filter source (for example, "v9.2.0" or "latest"). Fetches the release, parses PR references from the release notes, and removes changelogs whose PR URLs match — equivalent to passing the PR list using --prs. /// GitHub repository name, which is used when PRs or issues are specified as numbers or when --release-version is used. Falls back to bundle.repo in changelog.yml when not specified. If that value is also absent, the product ID is used. - /// Optional (option-based mode only): URL or file path to a promotion report. Extracts PR URLs and uses them as the filter. Mutually exclusive with --all, --products, --prs, --release-version, and --issues. + /// Optional (option-based mode only): URL or file path to a promotion report. Extracts PR URLs and uses them as the filter. Mutually exclusive with --all, --products, --prs, --release-version, --issues, and --files. /// [CommandIntent(Intent.Destructive | Intent.RequiresConfirmation)] [MutationScope(MutationScope.Directory)] @@ -959,6 +976,7 @@ public async Task Remove( [DryRun] bool dryRun = false, [ConfirmationSkip] bool force = false, string[]? issues = null, + string[]? files = null, string? owner = null, [ArgumentParser(typeof(ProductInfoParser))] ProductArgumentList? products = null, string[]? prs = null, @@ -978,10 +996,10 @@ public async Task Remove( // --release-version mode: resolve the release into a PR list and proceed as if --prs was specified if (releaseVersion != null) { - if (all || (products is { Count: > 0 }) || (prs is { Length: > 0 }) || (issues is { Length: > 0 })) + if (all || (products is { Count: > 0 }) || (prs is { Length: > 0 }) || (issues is { Length: > 0 }) || (files is { Length: > 0 })) { collector.EmitError(string.Empty, - "--release-version is mutually exclusive with --all, --products, --prs, and --issues."); + "--release-version is mutually exclusive with --all, --products, --prs, --issues, and --files."); return 1; } @@ -1022,6 +1040,7 @@ public async Task Remove( var allPrs = ExpandCommaSeparated(prs); var allIssues = ExpandCommaSeparated(issues); + var allFiles = ExpandCommaSeparated(files); if (isProfileMode) { @@ -1035,6 +1054,8 @@ public async Task Remove( forbidden.Add("--prs"); if (allIssues.Count > 0) forbidden.Add("--issues"); + if (allFiles.Count > 0) + forbidden.Add("--files"); if (releaseVersion != null) forbidden.Add("--release-version"); if (!string.IsNullOrWhiteSpace(repo)) @@ -1138,6 +1159,7 @@ public async Task Remove( Products = products, Prs = allPrs.Count > 0 ? allPrs.ToArray() : null, Issues = allIssues.Count > 0 ? allIssues.ToArray() : null, + Files = allFiles.Count > 0 ? allFiles.ToArray() : null, Owner = owner, Repo = repo, DryRun = dryRun, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs new file mode 100644 index 0000000000..ca50110585 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs @@ -0,0 +1,349 @@ +// 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 BundleFilesFilterTests : ChangelogTestBase +{ + private ChangelogBundlingService ServiceWithConfig { get; } + private readonly string _changelogDir; + + // language=yaml + private const string EntryKeep = + """ + title: Keep me + type: feature + products: + - product: elasticsearch + target: 9.3.0 + lifecycle: ga + """; + + // language=yaml + private const string EntrySkip = + """ + title: Skip me + type: feature + products: + - product: elasticsearch + target: 9.3.0 + lifecycle: ga + """; + + // language=yaml + private const string EntryBugFix = + """ + title: Bug fix + type: bug-fix + products: + - product: elasticsearch + target: 9.3.0 + lifecycle: ga + """; + + public BundleFilesFilterTests(ITestOutputHelper output) : base(output) + { + ServiceWithConfig = new(LoggerFactory, ConfigurationContext, FileSystem); + _changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(_changelogDir); + } + + [Fact] + public async Task Bundle_WithFiles_IncludesOnlyNamedEntries() + { + var keep = FileSystem.Path.Join(_changelogDir, "keep.yaml"); + var skip = FileSystem.Path.Join(_changelogDir, "skip.yaml"); + await FileSystem.File.WriteAllTextAsync(keep, EntryKeep, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(skip, EntrySkip, TestContext.Current.CancellationToken); + + var output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Files = [keep], + Output = output, + ForceLocal = true + }; + + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + var bundle = await FileSystem.File.ReadAllTextAsync(output, TestContext.Current.CancellationToken); + bundle.Should().Contain("name: keep.yaml"); + bundle.Should().NotContain("name: skip.yaml"); + } + + [Fact] + public async Task Bundle_WithFiles_MissingFile_ReturnsError() + { + var missing = FileSystem.Path.Join(_changelogDir, "missing.yaml"); + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Files = [missing], + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") + }; + + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("File does not exist")); + } + + [Fact] + public async Task Bundle_WithFilesAndPrs_ReturnsMutualExclusivityError() + { + var keep = FileSystem.Path.Join(_changelogDir, "keep.yaml"); + await FileSystem.File.WriteAllTextAsync(keep, EntryKeep, TestContext.Current.CancellationToken); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Files = [keep], + Prs = ["https://github.com/elastic/elasticsearch/pull/1"], + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") + }; + + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("Multiple filter options")); + } + + [Fact] + public async Task Bundle_WithPathListFile_IncludesListedEntries() + { + var keep = FileSystem.Path.Join(_changelogDir, "keep.yaml"); + var skip = FileSystem.Path.Join(_changelogDir, "skip.yaml"); + await FileSystem.File.WriteAllTextAsync(keep, EntryKeep, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(skip, EntrySkip, TestContext.Current.CancellationToken); + + var listFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "files.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(listFile)!); + await FileSystem.File.WriteAllTextAsync(listFile, $"{keep}\n", TestContext.Current.CancellationToken); + + var output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Files = [listFile], + Output = output + }; + + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + var bundle = await FileSystem.File.ReadAllTextAsync(output, TestContext.Current.CancellationToken); + bundle.Should().Contain("name: keep.yaml"); + bundle.Should().NotContain("name: skip.yaml"); + } + + [Fact] + public async Task Bundle_WithProfile_PathListFile_FiltersCorrectly() + { + var configContent = $""" + bundle: + directory: {_changelogDir} + profiles: + release: + output: "bundle.yaml" + """; + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var keep = FileSystem.Path.Join(_changelogDir, "keep.yaml"); + var skip = FileSystem.Path.Join(_changelogDir, "skip.yaml"); + await FileSystem.File.WriteAllTextAsync(keep, EntryKeep, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(skip, EntrySkip, TestContext.Current.CancellationToken); + + var listFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "files.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(listFile)!); + await FileSystem.File.WriteAllTextAsync(listFile, "keep.yaml\n", TestContext.Current.CancellationToken); + + var expectedOutput = FileSystem.Path.Join(_changelogDir, "bundle.yaml"); + var input = new BundleChangelogsArguments + { + Config = configPath, + Profile = "release", + ProfileArgument = "9.3.0", + ProfileReport = listFile + }; + + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + var bundle = await FileSystem.File.ReadAllTextAsync(expectedOutput, TestContext.Current.CancellationToken); + bundle.Should().Contain("name: keep.yaml"); + bundle.Should().NotContain("name: skip.yaml"); + } + + [Fact] + public async Task Bundle_WithProfile_MixedUrlsAndPaths_ReturnsError() + { + var configContent = + """ + bundle: + profiles: + release: + output: "bundle.yaml" + """; + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var listFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "mixed.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(listFile)!); + await FileSystem.File.WriteAllTextAsync( + listFile, + "https://github.com/elastic/elasticsearch/pull/100\nkeep.yaml\n", + TestContext.Current.CancellationToken); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Config = configPath, + Profile = "release", + ProfileArgument = listFile + }; + + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("not a mix")); + } + + [Fact] + public async Task Bundle_WithFiles_RulesBundleStillApplies() + { + var feature = FileSystem.Path.Join(_changelogDir, "feature.yaml"); + var bugFix = FileSystem.Path.Join(_changelogDir, "bug-fix.yaml"); + await FileSystem.File.WriteAllTextAsync(feature, EntryKeep, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(bugFix, EntryBugFix, TestContext.Current.CancellationToken); + + var configContent = $""" + bundle: + directory: {_changelogDir} + use_local_changelogs: true + rules: + bundle: + exclude_types: bug-fix + """; + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + var input = new BundleChangelogsArguments + { + Config = configPath, + Files = [feature, bugFix], + Output = output + }; + + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + var bundle = await FileSystem.File.ReadAllTextAsync(output, TestContext.Current.CancellationToken); + bundle.Should().Contain("name: feature.yaml"); + bundle.Should().NotContain("name: bug-fix.yaml"); + } + + [Fact] + public async Task Bundle_WithFiles_ForcesLocalEvenWhenRepoResolves() + { + var keep = FileSystem.Path.Join(_changelogDir, "keep.yaml"); + await FileSystem.File.WriteAllTextAsync(keep, EntryKeep, TestContext.Current.CancellationToken); + + var configContent = $""" + bundle: + directory: {_changelogDir} + repo: elasticsearch + """; + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + var fetcher = new CdnChangelogEntryFetcher(LoggerFactory, handler, sleep: (_, _) => Task.CompletedTask); + var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, null, fetcher); + + var output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + var input = new BundleChangelogsArguments + { + Config = configPath, + Files = [keep], + Output = output, + Resolve = true + }; + + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + handler.RequestedPaths.Should().BeEmpty("--files must force local sourcing"); + var bundle = await FileSystem.File.ReadAllTextAsync(output, TestContext.Current.CancellationToken); + bundle.Should().Contain("Keep me"); + } + + [Fact] + public async Task Bundle_WithForceLocal_SourcesLocalDespiteResolvableRepo() + { + var local = FileSystem.Path.Join(_changelogDir, "1-local.yaml"); + await FileSystem.File.WriteAllTextAsync(local, EntryKeep, TestContext.Current.CancellationToken); + + var configContent = $""" + bundle: + directory: {_changelogDir} + repo: elasticsearch + """; + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + var fetcher = new CdnChangelogEntryFetcher(LoggerFactory, handler, sleep: (_, _) => Task.CompletedTask); + var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, null, fetcher); + + var output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + var input = new BundleChangelogsArguments + { + Config = configPath, + All = true, + ForceLocal = true, + Output = output, + Resolve = true + }; + + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + handler.RequestedPaths.Should().BeEmpty("--force-local must not reach the CDN"); + var bundle = await FileSystem.File.ReadAllTextAsync(output, TestContext.Current.CancellationToken); + bundle.Should().Contain("name: 1-local.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.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs index cf64966594..fea65b61a7 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs @@ -1018,4 +1018,61 @@ public async Task Remove_WithBundleOwnerConfig_CliOwnerTakesPrecedence() Collector.Errors.Should().Be(0); FileExists("pr-100.yaml").Should().BeFalse("changelog should be removed when CLI owner matches"); } + + // ─── --files / path-list filter ───────────────────────────────────────────────── + + [Fact] + public async Task Remove_WithFiles_DeletesOnlyNamedFiles() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + await WriteFile("2001-kibana-feature.yaml", KibanaFeatureYaml); + + var keepPath = FileSystem.Path.Join(_changelogDir, "1001-es-feature.yaml"); + var input = new ChangelogRemoveArguments + { + Directory = _changelogDir, + Files = [keepPath] + }; + + var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + FileExists("1001-es-feature.yaml").Should().BeFalse(); + FileExists("2001-kibana-feature.yaml").Should().BeTrue(); + } + + [Fact] + public async Task Remove_WithProfile_PathListFile_RemovesListedFiles() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + await WriteFile("2001-kibana-feature.yaml", KibanaFeatureYaml); + + var configContent = $""" + bundle: + directory: {_changelogDir} + profiles: + release: + """; + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var listFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "files.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(listFile)!); + await FileSystem.File.WriteAllTextAsync(listFile, "1001-es-feature.yaml\n", TestContext.Current.CancellationToken); + + var input = new ChangelogRemoveArguments + { + Config = configPath, + Profile = "release", + ProfileArgument = "9.3.0", + ProfileReport = listFile + }; + + var result = await ServiceWithConfig.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + FileExists("1001-es-feature.yaml").Should().BeFalse(); + FileExists("2001-kibana-feature.yaml").Should().BeTrue(); + } } From 6f309140a4bd088735609b62a2d99c1f548405aa Mon Sep 17 00:00:00 2001 From: lcawl Date: Mon, 20 Jul 2026 08:03:47 -0500 Subject: [PATCH 2/3] Fix IDE0002 in FileFilterLoader --- src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs b/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs index 64182cdee9..0c404ba742 100644 --- a/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs +++ b/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs @@ -162,7 +162,7 @@ private void EmitMissingFileError(IDiagnosticsCollector collector, string file, internal static bool IsYamlExtension(string path) { - var ext = System.IO.Path.GetExtension(path); + var ext = Path.GetExtension(path); return ext.Equals(".yaml", StringComparison.OrdinalIgnoreCase) || ext.Equals(".yml", StringComparison.OrdinalIgnoreCase); } From 8d17f44318694c826a070f05d1af468a90f2356c Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Mon, 20 Jul 2026 22:44:43 -0300 Subject: [PATCH 3/3] Implement tilde expansion in FileFilterLoader and drop dead ResolveUrlListFileAsync shim The --files missing-file error promised tilde (~) expansion that was never performed. Extract the expansion already implemented in FeatureHidingLoader into FilterLoaderUtilities.ExpandTilde, reuse it from both loaders, and remove the uncalled ResolveUrlListFileAsync wrapper left behind by the rename to ResolveListFileAsync. --- .../Bundling/FileFilterLoader.cs | 12 ++--- .../Bundling/FilterLoaderUtilities.cs | 24 ++++++++++ .../Bundling/ProfileFilterResolver.cs | 11 ----- .../Rendering/FeatureHidingLoader.cs | 22 ++------- .../Utilities/FilterLoaderUtilitiesTests.cs | 45 +++++++++++++++++++ 5 files changed, 79 insertions(+), 35 deletions(-) create mode 100644 tests/Elastic.Changelog.Tests/Utilities/FilterLoaderUtilitiesTests.cs diff --git a/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs b/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs index 0c404ba742..faac64486c 100644 --- a/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs +++ b/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs @@ -30,11 +30,12 @@ public async Task LoadFilesAsync( if (files is not { Length: > 0 }) return new FileFilterResult { IsValid = true, FilePaths = resolved }; - foreach (var value in files) + foreach (var rawValue in files) { - if (string.IsNullOrWhiteSpace(value)) + if (string.IsNullOrWhiteSpace(rawValue)) continue; + var value = FilterLoaderUtilities.ExpandTilde(rawValue); if (IsPathListFile(value)) { if (!await ReadPathListFileAsync(collector, value, baseDirectory, resolved, ctx)) @@ -136,12 +137,13 @@ private bool IsPathListFile(string value) private string? ResolveChangelogPath(string value, string? baseDirectory) { - if (fileSystem.File.Exists(value)) - return value; + var expanded = FilterLoaderUtilities.ExpandTilde(value); + if (fileSystem.File.Exists(expanded)) + return expanded; if (!string.IsNullOrWhiteSpace(baseDirectory)) { - var joined = fileSystem.Path.Join(baseDirectory, value); + var joined = fileSystem.Path.Join(baseDirectory, expanded); if (fileSystem.File.Exists(joined)) return joined; } diff --git a/src/services/Elastic.Changelog/Bundling/FilterLoaderUtilities.cs b/src/services/Elastic.Changelog/Bundling/FilterLoaderUtilities.cs index d50ed5c715..9c672c5319 100644 --- a/src/services/Elastic.Changelog/Bundling/FilterLoaderUtilities.cs +++ b/src/services/Elastic.Changelog/Bundling/FilterLoaderUtilities.cs @@ -12,6 +12,30 @@ namespace Elastic.Changelog.Bundling; /// internal static class FilterLoaderUtilities { + /// + /// Expands a leading tilde (~) to the user's home directory. Other paths are returned trimmed. + /// + internal static string ExpandTilde(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return path; + + var trimmedPath = path.Trim(); + if (trimmedPath == "~") + return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + if (!trimmedPath.StartsWith("~/", StringComparison.Ordinal) && !trimmedPath.StartsWith("~\\", StringComparison.Ordinal)) + return trimmedPath; + + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var relativePath = trimmedPath[2..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + // Ensure that an accidentally rooted path segment does not cause the home directory + // to be ignored by Path.Join. + return Path.IsPathRooted(relativePath) + ? relativePath + : Path.Join(homeDirectory, relativePath); + } + private static bool IsUrl(string value) => value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase); diff --git a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs index eaf9b4d600..ccedc482d4 100644 --- a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs +++ b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs @@ -356,17 +356,6 @@ public static partial class ProfileFilterResolver : new ListFileResult(null, lines, null); } - /// - /// Reads a newline-delimited URL list file and validates/classifies its contents as PR or issue URLs. - /// Returns null and emits errors on failure. - /// - internal static async Task ResolveUrlListFileAsync( - IDiagnosticsCollector collector, - string filePath, - ScopedFileSystem fileSystem, - Cancel ctx) => - await ResolveListFileAsync(collector, filePath, fileSystem, ctx); - private static ProfileArgumentType DetectLocalFileType(ScopedFileSystem fileSystem, string path) => fileSystem.Path.GetExtension(path).ToLowerInvariant() is ".html" or ".htm" ? ProfileArgumentType.PromotionReportFile diff --git a/src/services/Elastic.Changelog/Rendering/FeatureHidingLoader.cs b/src/services/Elastic.Changelog/Rendering/FeatureHidingLoader.cs index 1c45499ece..c7e1bf4058 100644 --- a/src/services/Elastic.Changelog/Rendering/FeatureHidingLoader.cs +++ b/src/services/Elastic.Changelog/Rendering/FeatureHidingLoader.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using Elastic.Changelog.Bundling; using Elastic.Documentation.Diagnostics; namespace Elastic.Changelog.Rendering; @@ -162,27 +163,10 @@ private static string NormalizePath(string path) if (string.IsNullOrWhiteSpace(path)) return path; - var trimmedPath = path.Trim(); - - // Expand tilde to user's home directory - if (trimmedPath.StartsWith("~/", StringComparison.Ordinal) || trimmedPath.StartsWith("~\\", StringComparison.Ordinal)) - { - var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var relativePath = trimmedPath[2..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - // Ensure that an accidentally rooted path segment does not cause the home directory - // to be ignored by Path.Combine. - var fullPath = Path.IsPathRooted(relativePath) - ? relativePath - : Path.Join(homeDirectory, relativePath); - trimmedPath = fullPath; - } - else if (trimmedPath == "~") - { - trimmedPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - } + var expanded = FilterLoaderUtilities.ExpandTilde(path); // Convert to absolute path (handles relative paths like ./file or ../file) - return Path.GetFullPath(trimmedPath); + return Path.GetFullPath(expanded); } } diff --git a/tests/Elastic.Changelog.Tests/Utilities/FilterLoaderUtilitiesTests.cs b/tests/Elastic.Changelog.Tests/Utilities/FilterLoaderUtilitiesTests.cs new file mode 100644 index 0000000000..411e21e76a --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Utilities/FilterLoaderUtilitiesTests.cs @@ -0,0 +1,45 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using AwesomeAssertions; +using Elastic.Changelog.Bundling; + +namespace Elastic.Changelog.Tests.Utilities; + +public class FilterLoaderUtilitiesTests +{ + private static readonly string Home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + [Fact] + public void ExpandTilde_BareTilde_ReturnsHomeDirectory() + { + var result = FilterLoaderUtilities.ExpandTilde("~"); + + result.Should().Be(Home); + } + + [Fact] + public void ExpandTilde_TildePrefixedPath_ExpandsToHomeDirectory() + { + var result = FilterLoaderUtilities.ExpandTilde("~/docs/changelog/entry.yaml"); + + result.Should().Be(Path.Join(Home, "docs/changelog/entry.yaml")); + } + + [Fact] + public void ExpandTilde_RelativePath_ReturnsTrimmedPathUnchanged() + { + var result = FilterLoaderUtilities.ExpandTilde(" docs/changelog/entry.yaml "); + + result.Should().Be("docs/changelog/entry.yaml"); + } + + [Fact] + public void ExpandTilde_TildeInMiddleOfPath_ReturnsPathUnchanged() + { + var result = FilterLoaderUtilities.ExpandTilde("docs/~backup/entry.yaml"); + + result.Should().Be("docs/~backup/entry.yaml"); + } +}