From 1ef069ef38627e18f062fc54c70c7058b6a84a1a Mon Sep 17 00:00:00 2001 From: lcawl Date: Thu, 26 Feb 2026 18:38:16 -0800 Subject: [PATCH 1/2] =?UTF-8?q?Implement=20Phases=203=E2=80=935:=20URL=20l?= =?UTF-8?q?ist=20files,=20--report=20option,=20stricter=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 (profile-based enhancements): - Accept a newline-delimited URL list file as a profile argument, resolving to PR or issue filters based on the URLs found in the file - Support combined profile arguments so the version can be used for {version} substitution while the report/file drives filtering - Wire Issues filter through ProfileFilterResult into bundling and remove services Phase 4 (option-based enhancements): - Add --report option to both `changelog bundle` and `changelog remove`, accepting a promotion report URL or local HTML file path - Enforce stricter validation for file-based --prs and --issues inputs: every line must be a fully-qualified GitHub URL Phase 5 (tests and documentation): - Add 264-test coverage for all new behaviours in BundleChangelogsTests and ChangelogRemoveTests - Update changelog-bundle.md, changelog-remove.md, and contribute/changelog.md to document new arguments, options, validation rules, and examples Made-with: Cursor --- docs/cli/release/changelog-bundle.md | 69 +- docs/cli/release/changelog-remove.md | 49 +- docs/contribute/changelog.md | 27 +- .../Bundling/ChangelogBundlingService.cs | 28 +- .../Bundling/ChangelogRemoveService.cs | 28 +- .../Bundling/IssueFilterLoader.cs | 34 +- .../Bundling/PrFilterLoader.cs | 43 +- .../Bundling/ProfileFilterResolver.cs | 252 ++++++- .../Bundling/PromotionReportParser.cs | 4 +- .../ChangelogConfigurationLoader.cs | 20 +- .../docs-builder/Commands/ChangelogCommand.cs | 68 +- .../Changelogs/BundleChangelogsTests.cs | 650 ++++++++++++++++++ .../Changelogs/ChangelogRemoveTests.cs | 118 ++++ 13 files changed, 1284 insertions(+), 106 deletions(-) diff --git a/docs/cli/release/changelog-bundle.md b/docs/cli/release/changelog-bundle.md index 82bc778a3..fb1028854 100644 --- a/docs/cli/release/changelog-bundle.md +++ b/docs/cli/release/changelog-bundle.md @@ -28,20 +28,29 @@ These arguments apply to profile-based bundling: `[0] ` : Profile name from `bundle.profiles` in the changelog configuration file. : For example, "elasticsearch-release". -: When it's specified, the second argument is the version or promotion report URL. +: When specified, the second argument is the version, promotion report URL, or URL list file. `[1] ` -: Version number or promotion report URL or path. -: For example, "9.2.0" or `https://buildkite.../promotion-report.html`. +: Version number, promotion report URL/path, or URL list file. +: For example, `9.2.0`, `https://buildkite.../promotion-report.html`, or `/path/to/prs.txt`. + +`[2] ` +: Optional: Promotion report URL/path or URL list file when the second argument is a version string. +: When provided, `[1]` must be a version string and `[2]` is the PR/issue filter source. +: For example, `docs-builder changelog bundle serverless-release 2026-02 ./promotion-report.html`. :::{note} -Only the profile-based method currently supports buildkite promotion reports. -There is no equivalent command option. +The third argument (`[2]`) is required when your profile uses `{version}` placeholders in `output` or `output_products` patterns and you also want to filter by a promotion report or URL list. Without it, the version defaults to `"unknown"`. ::: - +### Profile argument types + +The second argument (`[1]`) and optional third argument (`[2]`) accept the following: + +- **Version string** — Used for `{version}` substitution in profile patterns. For example, `9.2.0` or `2026-02`. +- **Promotion report URL** — A URL to an HTML promotion report. PR URLs are extracted from it. +- **Promotion report file** — A path to a local `.html` file containing a promotion report. +- **URL list file** — A path to a plain-text file containing one fully-qualified GitHub PR or issue URL per line. For example, `https://github.com/elastic/elasticsearch/pull/123`. The file must contain only PR URLs or only issue URLs, not a mix. Bare numbers and short forms such as `owner/repo#123` are not allowed. ## Options @@ -79,11 +88,10 @@ Using any of them with a profile returns an error. - `"* * *"` - match all changelogs (equivalent to `--all`) `--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. -: Only one filter option can be specified: `--all`, `--input-products`, `--prs`, or `--issues`. -: Each occurrence can be either comma-separated issues ( `--issues "https://github.com/owner/repo/issues/123,456"`) or a file path (for example `--issues /path/to/file.txt`). -: When specifying issues directly, provide comma-separated values. -: When specifying a file path, provide a single value that points to a newline-delimited file. +: Filter by issue URLs (comma-separated), or a path to a newline-delimited file. Can be specified multiple times. +: Only one filter option can be specified: `--all`, `--input-products`, `--prs`, `--issues`, or `--report`. +: When specifying inline values, comma-separated issue numbers are allowed when `--owner` and `--repo` are also provided. +: When using a file, every line must be a fully-qualified GitHub issue URL such as `https://github.com/owner/repo/issues/123`. Bare numbers and short forms are not allowed in files. `--no-resolve` : Optional: Explicitly turn off the `resolve` option if it's specified in the changelog configuration file. @@ -102,11 +110,15 @@ Using any of them with a profile returns an error. : Falls back to `bundle.owner` in `changelog.yml` when not specified. `--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. -: Only one filter option can be specified: `--all`, `--input-products`, `--prs`, or `--issues`. -: Each occurrence can be either comma-separated PRs (for example `--prs "https://github.com/owner/repo/pull/123,6789"`) or a file path (for example `--prs /path/to/file.txt`). -: When specifying PRs directly, provide comma-separated values. -: When specifying a file path, provide a single value that points to a newline-delimited file. +: Filter by pull request URLs (comma-separated), or a path to a newline-delimited file. Can be specified multiple times. +: Only one filter option can be specified: `--all`, `--input-products`, `--prs`, `--issues`, or `--report`. +: When specifying inline values, comma-separated PR numbers are allowed when `--owner` and `--repo` are also provided. +: When using a file, every line must be a fully-qualified GitHub PR URL such as `https://github.com/owner/repo/pull/123`. Bare numbers and short forms are not allowed in files. + +`--report ` +: Filter by pull requests extracted from a promotion report. Accepts a URL or a local file path. +: Only one filter option can be specified: `--all`, `--input-products`, `--prs`, `--issues`, or `--report`. +: The report can be an HTML page from Buildkite or any file containing GitHub PR URLs. `--repo ` : Optional: The GitHub repository name, required when pull requests or issues are specified as numbers. @@ -280,12 +292,21 @@ docs-builder changelog bundle elasticsearch-release 9.2.0 # Bundle changelogs with partial dates docs-builder changelog bundle serverless-monthly 2026-02 -``` - diff --git a/docs/cli/release/changelog-remove.md b/docs/cli/release/changelog-remove.md index 95437a703..af40db6c2 100644 --- a/docs/cli/release/changelog-remove.md +++ b/docs/cli/release/changelog-remove.md @@ -26,12 +26,18 @@ These arguments apply to profile-based removal: `[0] ` : Profile name from `bundle.profiles` in the changelog configuration file. : For example, "elasticsearch-release". -: When specified, the second argument is the version or promotion report URL. -: Mutually exclusive with `--all`, `--products`, `--prs`, `--issues`, `--owner`, `--repo`, `--config`, `--directory`, and `--bundles-dir`. +: When specified, the second argument is the version, promotion report URL, or URL list file. +: Mutually exclusive with `--all`, `--products`, `--prs`, `--issues`, `--report`, `--owner`, `--repo`, `--config`, `--directory`, and `--bundles-dir`. `[1] ` -: Version number or promotion report URL or path. -: For example, "9.2.0" or "https://buildkite.../promotion-report.html". +: Version number, promotion report URL/path, or URL list file. +: For example, `9.2.0`, `https://buildkite.../promotion-report.html`, or `/path/to/prs.txt`. +: See [Profile argument types](/cli/release/changelog-bundle.md#profile-argument-types) for details on accepted formats. + +`[2] ` +: Optional: Promotion report URL/path or URL list file when the second argument is a version string. +: When provided, `[1]` must be a version string and `[2]` is the PR/issue filter source. +: For example, `docs-builder changelog remove serverless-release 2026-02 ./promotion-report.html`. ## Options @@ -65,9 +71,10 @@ These arguments apply to profile-based removal: : Valid in both profile and option-based mode. `--issues ` -: Filter by issue URLs or numbers (comma-separated), or a path to a newline-delimited file containing issue URLs or numbers. +: Filter by issue URLs (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`. +: Exactly one filter option must be specified: `--all`, `--products`, `--prs`, `--issues`, or `--report`. +: When using a file, every line must be a fully-qualified GitHub issue URL. Bare numbers and short forms are not allowed in files. : Not allowed with a profile argument. `--owner ` @@ -87,15 +94,21 @@ These arguments apply to profile-based removal: - `"* * *"` — 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. +: Filter by pull request URLs (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`. +: Exactly one filter option must be specified: `--all`, `--products`, `--prs`, `--issues`, or `--report`. +: When using a file, every line must be a fully-qualified GitHub PR URL. Bare numbers and short forms are not allowed in files. : Not allowed with a profile argument. `--repo ` : The GitHub repository name, which is required when pull requests or issues are specified as numbers. : Not allowed with a profile argument. +`--report ` +: Filter by pull requests extracted from a promotion report. Accepts a URL or a local file path. +: Exactly one filter option must be specified: `--all`, `--products`, `--prs`, `--issues`, or `--report`. +: Not allowed with a profile argument. + ## Profile-based removal [changelog-remove-profile] When a `changelog.yml` configuration file defines `bundle.profiles`, you can use those same profiles with `changelog remove` to remove exactly the changelogs that would be included in a matching bundle. @@ -127,3 +140,23 @@ You can also pass a promotion report URL or file path as the second argument, in ```sh docs-builder changelog remove elasticsearch-release https://buildkite.../promotion-report.html ``` + +When using a profile with `{version}` in the `output` or `output_products` pattern, pass the version as the second argument and the report as the third: + +```sh +docs-builder changelog remove serverless-release 2026-02 ./promotion-report.html +``` + +Or with a URL list file: + +```sh +docs-builder changelog remove serverless-release 2026-02 ./prs.txt +``` + +For option-based removal with a promotion report: + +```sh +docs-builder changelog remove \ + --report https://buildkite.../promotion-report.html \ + --directory ./docs/changelog +``` diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index 7d3f4b434..12a8a3aa7 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -500,8 +500,9 @@ You can specify only one of the following filter options: - `--all`: Include all changelogs from the directory. - `--input-products`: Include changelogs for the specified products. Refer to [Filter by product](#changelog-bundle-product). -- `--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). +- `--prs`: Include changelogs for the specified pull request URLs, or a path to a newline-delimited file. When using a file, every line must be a fully-qualified GitHub URL such as `https://github.com/owner/repo/pull/123`. Go to [Filter by pull requests](#changelog-bundle-pr). +- `--issues`: Include changelogs for the specified issue URLs, or a path to a newline-delimited file. When using a file, every line must be a fully-qualified GitHub URL such as `https://github.com/owner/repo/issues/123`. Go to [Filter by issues](#changelog-bundle-issues). +- `--report`: Include changelogs whose pull requests appear in a promotion report. Accepts a URL or a local file path to an HTML report. By default, the output file contains only the changelog file names and checksums. To change this behavior, set `bundle.resolve` to `true` in the changelog configuration file or use the `--resolve` command option. @@ -520,7 +521,17 @@ When you do not specify `--repo` or `--owner`, the command falls back to `bundle If your `changelog.yml` configuration file defines `bundle.profiles`, you can run a bundle by profile name instead of supplying individual options: ```sh -docs-builder changelog bundle +docs-builder changelog bundle +``` + +The second argument accepts a version string, a promotion report URL/path, or a URL list file (a plain-text file with one fully-qualified GitHub URL per line). When your profile uses `{version}` in its `output` or `output_products` pattern and you also want to filter by a report, pass both: + +```sh +# Version + report (version for substitution, report for filtering) +docs-builder changelog bundle serverless-release 2026-02 ./promotion-report.html + +# Version + URL list file +docs-builder changelog bundle serverless-release 2026-02 ./prs.txt ``` For example: @@ -528,6 +539,7 @@ For example: ```sh docs-builder changelog bundle elasticsearch-release 9.2.0 docs-builder changelog bundle elasticsearch-release ./promotion-report.html +docs-builder changelog bundle elasticsearch-release 9.2.0 ./promotion-report.html ``` The command automatically discovers `changelog.yml` by checking `./changelog.yml` then `./docs/changelog.yml` relative to your current directory. @@ -927,7 +939,7 @@ This is the easiest way to remove exactly the changelogs that were included in a The command syntax is: ```sh -docs-builder changelog remove +docs-builder changelog remove ``` For example, if you bundled with: @@ -951,16 +963,19 @@ The `output`, `output_products`, `repo`, `owner`, and `hide_features` fields are Profile-based removal is mutually exclusive with command options. The only options allowed alongside a profile name are `--dry-run` and `--force`. -You can also pass a promotion report URL or file path as the second argument, and the command removes changelogs whose pull request URLs appear in the report: +You can also pass a promotion report URL, file path, or URL list file as the second argument, and the command removes changelogs whose pull request or issue URLs appear in the report: ```sh docs-builder changelog remove elasticsearch-release https://buildkite.../promotion-report.html +docs-builder changelog remove serverless-release 2026-02 ./promotion-report.html +docs-builder changelog remove serverless-release 2026-02 ./prs.txt ``` ### Removal with command options [changelog-remove-raw] You can alternatively remove changelogs based on their issues, pull requests, product metadata, or remove all changelogs from a folder. -Exactly one filter option must be specified: `--all`, `--products`, `--prs`, or `--issues`. +Exactly one filter option must be specified: `--all`, `--products`, `--prs`, `--issues`, or `--report`. +When using a file for `--prs` or `--issues`, every line must be a fully-qualified GitHub URL. ```sh docs-builder changelog remove --products "elasticsearch 9.3.0 *" --dry-run diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 1d02aa9e2..fe5714113 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -51,6 +51,18 @@ public record BundleChangelogsArguments /// public string? ProfileArgument { get; init; } + /// + /// Optional third profile argument: a promotion report URL/path or URL list file to use as the + /// PR/issue filter source when is the version string. + /// + public string? ProfileReport { get; init; } + + /// + /// Promotion report URL or file path for option-based bundling (--report). + /// When set, the report is parsed and the extracted PR URLs become the effective PR filter. + /// + public string? Report { get; init; } + /// /// Output directory for bundled changelog files (from config bundle.output_directory) /// @@ -126,6 +138,18 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle return false; input = profileResult; } + else if (!string.IsNullOrWhiteSpace(input.Report)) + { + // Option-based mode with --report: parse report and populate Prs + var parser = new PromotionReportParser(logFactory, _fileSystem); + var reportResult = await parser.ParsePromotionReportAsync(input.Report, ctx); + if (!reportResult.IsValid) + { + collector.EmitError(string.Empty, reportResult.ErrorMessage ?? "Failed to parse promotion report"); + return false; + } + input = input with { Prs = reportResult.PrUrls.ToArray() }; + } // Apply config defaults if available input = ApplyConfigDefaults(input, config); @@ -234,7 +258,8 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle config, _fileSystem, _logger, - ctx + ctx, + input.ProfileReport ); if (filterResult == null) @@ -276,6 +301,7 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle { InputProducts = filterResult.Products, Prs = filterResult.Prs, + Issues = filterResult.Issues, All = false, Output = outputPath, OutputProducts = outputProducts, diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs index 040492885..bda7e4f7c 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs @@ -36,6 +36,18 @@ public record ChangelogRemoveArguments /// Version number or promotion report URL/path when using a profile. public string? ProfileArgument { get; init; } + + /// + /// Optional third profile argument: a promotion report URL/path or URL list file to use as the + /// PR/issue filter source when is the version string. + /// + public string? ProfileReport { get; init; } + + /// + /// Promotion report URL or file path for option-based removal (--report). + /// When set, the report is parsed and the extracted PR URLs become the effective PR filter. + /// + public string? Report { get; init; } } /// @@ -93,7 +105,8 @@ public async Task RemoveChangelogs(IDiagnosticsCollector collector, Change config, _fileSystem, _logger, - ctx + ctx, + input.ProfileReport ); if (filterResult == null) @@ -103,9 +116,22 @@ public async Task RemoveChangelogs(IDiagnosticsCollector collector, Change { Products = filterResult.Products, Prs = filterResult.Prs, + Issues = filterResult.Issues, All = false }; } + else if (!string.IsNullOrWhiteSpace(input.Report)) + { + // Option-based mode with --report: parse report and populate Prs + var parser = new PromotionReportParser(logFactory, _fileSystem); + var reportResult = await parser.ParsePromotionReportAsync(input.Report, ctx); + if (!reportResult.IsValid) + { + collector.EmitError(string.Empty, reportResult.ErrorMessage ?? "Failed to parse promotion report"); + return false; + } + input = input with { Prs = reportResult.PrUrls.ToArray() }; + } input = ApplyConfigDefaults(input, config); diff --git a/src/services/Elastic.Changelog/Bundling/IssueFilterLoader.cs b/src/services/Elastic.Changelog/Bundling/IssueFilterLoader.cs index 933dde00a..e107303ae 100644 --- a/src/services/Elastic.Changelog/Bundling/IssueFilterLoader.cs +++ b/src/services/Elastic.Changelog/Bundling/IssueFilterLoader.cs @@ -50,7 +50,14 @@ public async Task LoadIssuesAsync( } else { - await ProcessMultipleValuesAsync(issuesToMatch, nonExistentFiles, issues, ctx); + if (!await ProcessMultipleValuesAsync(collector, issuesToMatch, nonExistentFiles, issues, ctx)) + { + return new IssueFilterResult + { + IsValid = false, + IssuesToMatch = issuesToMatch + }; + } if (nonExistentFiles.Count > 0) { @@ -64,6 +71,7 @@ public async Task LoadIssuesAsync( }; } + // Emit warnings for non-existent files since we have valid issues foreach (var file in nonExistentFiles) collector.EmitWarning(file, $"File does not exist, skipping: {file}"); } @@ -95,8 +103,7 @@ private async Task ProcessSingleValueAsync( if (!isUrl && fileSystem.File.Exists(singleValue)) { - await ReadIssuesFromFileAsync(singleValue, issuesToMatch, ctx); - return true; + return await ReadIssuesFromFileAsync(collector, singleValue, issuesToMatch, ctx); } if (!isUrl) @@ -121,7 +128,8 @@ private async Task ProcessSingleValueAsync( return true; } - private async Task ProcessMultipleValuesAsync( + private async Task ProcessMultipleValuesAsync( + IDiagnosticsCollector collector, HashSet issuesToMatch, List nonExistentFiles, string[] values, @@ -133,7 +141,8 @@ private async Task ProcessMultipleValuesAsync( if (!isUrl && fileSystem.File.Exists(value)) { - await ReadIssuesFromFileAsync(value, issuesToMatch, ctx); + if (!await ReadIssuesFromFileAsync(collector, value, issuesToMatch, ctx)) + return false; } else if (isUrl) { @@ -152,6 +161,7 @@ private async Task ProcessMultipleValuesAsync( _ = issuesToMatch.Add(value); } } + return true; } private static bool ValidateNumericIssues( @@ -174,7 +184,7 @@ private static bool ValidateNumericIssues( return true; } - private async Task ReadIssuesFromFileAsync(string filePath, HashSet issuesToMatch, Cancel ctx) + private async Task ReadIssuesFromFileAsync(IDiagnosticsCollector collector, string filePath, HashSet issuesToMatch, Cancel ctx) { var content = await fileSystem.File.ReadAllTextAsync(filePath, ctx); var lines = content @@ -182,7 +192,19 @@ private async Task ReadIssuesFromFileAsync(string filePath, HashSet issu .Where(p => !string.IsNullOrWhiteSpace(p)); foreach (var line in lines) + { + if (!IsUrl(line)) + { + collector.EmitError( + filePath, + $"File must contain fully-qualified GitHub URLs (e.g. https://github.com/owner/repo/issues/123). " + + $"Numbers and short forms are not allowed. Found: {line}" + ); + return false; + } _ = issuesToMatch.Add(line); + } + return true; } private static bool IsUrl(string value) => diff --git a/src/services/Elastic.Changelog/Bundling/PrFilterLoader.cs b/src/services/Elastic.Changelog/Bundling/PrFilterLoader.cs index 4c124ccc2..bfe6ba3fc 100644 --- a/src/services/Elastic.Changelog/Bundling/PrFilterLoader.cs +++ b/src/services/Elastic.Changelog/Bundling/PrFilterLoader.cs @@ -51,7 +51,14 @@ public async Task LoadPrsAsync( } else { - await ProcessMultipleValuesAsync(prsToMatch, nonExistentFiles, prs, ctx); + if (!await ProcessMultipleValuesAsync(collector, prsToMatch, nonExistentFiles, prs, ctx)) + { + return new PrFilterResult + { + IsValid = false, + PrsToMatch = prsToMatch + }; + } // After processing all values, handle non-existent files if (nonExistentFiles.Count > 0) @@ -101,9 +108,8 @@ private async Task ProcessSingleValueAsync( if (!isUrl && fileSystem.File.Exists(singleValue)) { - // File exists, read PRs from it - await ReadPrsFromFileAsync(singleValue, prsToMatch, ctx); - return true; + // File exists, read PRs from it (file inputs must be fully-qualified URLs) + return await ReadPrsFromFileAsync(collector, singleValue, prsToMatch, ctx); } if (!isUrl) @@ -133,7 +139,8 @@ private async Task ProcessSingleValueAsync( return true; } - private async Task ProcessMultipleValuesAsync( + private async Task ProcessMultipleValuesAsync( + IDiagnosticsCollector collector, HashSet prsToMatch, List nonExistentFiles, string[] values, @@ -145,8 +152,9 @@ private async Task ProcessMultipleValuesAsync( if (!isUrl && fileSystem.File.Exists(value)) { - // File exists, read PRs from it - await ReadPrsFromFileAsync(value, prsToMatch, ctx); + // File exists, read PRs from it (file inputs must be fully-qualified URLs) + if (!await ReadPrsFromFileAsync(collector, value, prsToMatch, ctx)) + return false; } else if (isUrl) { @@ -169,6 +177,7 @@ private async Task ProcessMultipleValuesAsync( _ = prsToMatch.Add(value); } } + return true; } private static bool ValidateNumericPrs( @@ -206,15 +215,27 @@ private static bool ValidateNumericPrs( return true; } - private async Task ReadPrsFromFileAsync(string filePath, HashSet prsToMatch, Cancel ctx) + private async Task ReadPrsFromFileAsync(IDiagnosticsCollector collector, string filePath, HashSet prsToMatch, Cancel ctx) { var content = await fileSystem.File.ReadAllTextAsync(filePath, ctx); - var prs = content + var lines = content .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(p => !string.IsNullOrWhiteSpace(p)); - foreach (var pr in prs) - _ = prsToMatch.Add(pr); + foreach (var line in lines) + { + if (!IsUrl(line)) + { + 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 false; + } + _ = prsToMatch.Add(line); + } + return true; } private static bool IsUrl(string value) => diff --git a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs index 1f50b4ff6..e670c2748 100644 --- a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs +++ b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using System.Text.RegularExpressions; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Diagnostics; using Microsoft.Extensions.Logging; @@ -11,36 +12,51 @@ namespace Elastic.Changelog.Bundling; /// -/// The resolved filter derived from a bundle profile — either a product list or a set of PR URLs. -/// Exactly one of and will be non-null on a successful result. +/// 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. /// public record ProfileFilterResult { /// Product/target/lifecycle filters parsed from the profile's products pattern. public IReadOnlyList? Products { get; init; } - /// PR URLs extracted from a promotion report supplied as the profile argument. + /// PR URLs extracted from a promotion report or URL list file supplied as the profile argument. public string[]? Prs { get; init; } + /// Issue URLs extracted from a URL list file supplied as the profile argument. + public string[]? Issues { 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 was parsed (because the actual version is not available from the report). + /// a promotion report or URL 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"; } /// -/// Resolves a and a profile argument (version string or promotion -/// report URL/path) into a concrete filter that can be used by both +/// Resolves a and a profile argument (version string, promotion +/// report URL/path, or URL list file) into a concrete filter that can be used by both /// and . /// -public static class ProfileFilterResolver +public static partial class ProfileFilterResolver { + [GeneratedRegex(@"^https?://github\.com/[^/]+/[^/]+/pull/\d+/?$", RegexOptions.IgnoreCase)] + private static partial Regex GitHubPrUrlRegex(); + + [GeneratedRegex(@"^https?://github\.com/[^/]+/[^/]+/issues/\d+/?$", RegexOptions.IgnoreCase)] + private static partial Regex GitHubIssueUrlRegex(); + /// /// Resolves the profile into a , or returns null and /// emits diagnostics errors on failure. /// + /// + /// Optional third argument. When non-null, is treated as the + /// version string for {version} substitution and this value is used as the PR/issue filter + /// source (promotion report or URL list file). + /// public static async Task ResolveAsync( IDiagnosticsCollector collector, string profileName, @@ -48,7 +64,8 @@ public static class ProfileFilterResolver ChangelogConfiguration? config, IFileSystem fileSystem, ILogger? logger, - Cancel ctx) + Cancel ctx, + string? profileReport = null) { if (config?.Bundle?.Profiles == null || !config.Bundle.Profiles.TryGetValue(profileName, out var profile)) { @@ -62,35 +79,61 @@ public static class ProfileFilterResolver return null; } + // When a separate report argument is provided, profileArgument is always the version + if (profileReport != null) + return await ResolveWithSeparateReportAsync(collector, profileName, profileArgument, profileReport, profile, fileSystem, logger, ctx); + // Auto-detect argument type var argType = PromotionReportParser.DetectArgumentType(profileArgument); - // Treat an existing local file path as a promotion report + // Treat an existing local file path as promotion report (HTML) or URL list file (anything else) if (argType == ProfileArgumentType.Version && fileSystem.File.Exists(profileArgument)) - argType = ProfileArgumentType.PromotionReportFile; + { + var ext = fileSystem.Path.GetExtension(profileArgument).ToLowerInvariant(); + argType = ext is ".html" or ".htm" + ? ProfileArgumentType.PromotionReportFile + : ProfileArgumentType.UrlListFile; + } string version; string[]? prsFromReport = null; + string[]? issuesFromFile = null; - if (argType is ProfileArgumentType.PromotionReportUrl or ProfileArgumentType.PromotionReportFile) + switch (argType) { - var parser = new PromotionReportParser(NullLoggerFactory.Instance, fileSystem); - var reportResult = await parser.ParsePromotionReportAsync(profileArgument, ctx); + case ProfileArgumentType.PromotionReportUrl: + case ProfileArgumentType.PromotionReportFile: + { + var parser = new PromotionReportParser(NullLoggerFactory.Instance, fileSystem); + var reportResult = await parser.ParsePromotionReportAsync(profileArgument, ctx); - if (!reportResult.IsValid) - { - collector.EmitError(string.Empty, reportResult.ErrorMessage ?? "Failed to parse promotion report"); - return null; - } + if (!reportResult.IsValid) + { + collector.EmitError(string.Empty, reportResult.ErrorMessage ?? "Failed to parse promotion report"); + return null; + } - prsFromReport = reportResult.PrUrls.ToArray(); - logger?.LogInformation("Extracted {Count} PRs from promotion report", prsFromReport.Length); + prsFromReport = reportResult.PrUrls.ToArray(); + logger?.LogInformation("Extracted {Count} PRs from promotion report", prsFromReport.Length); + version = "unknown"; + break; + } + case ProfileArgumentType.UrlListFile: + { + var result = await ResolveUrlListFileAsync(collector, profileArgument, fileSystem, ctx); + if (result == null) + return null; - version = "unknown"; - } - else - { - version = profileArgument; + prsFromReport = result.Value.Prs; + issuesFromFile = result.Value.Issues; + version = "unknown"; + break; + } + default: + { + version = profileArgument; + break; + } } // Substitute {version} and {lifecycle} in the products pattern @@ -99,11 +142,14 @@ public static class ProfileFilterResolver .Replace("{version}", version) .Replace("{lifecycle}", lifecycle); - // If we have PRs from a report, return those directly + // If we have PRs or issues from a file/report, return those directly if (prsFromReport != null) return new ProfileFilterResult { Prs = prsFromReport, Version = version }; - // Without a promotion report we need a products pattern to filter by + 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 (string.IsNullOrWhiteSpace(productsPattern)) { collector.EmitError( @@ -117,6 +163,158 @@ public static class ProfileFilterResolver return new ProfileFilterResult { Products = products, Version = version }; } + /// + /// Handles the case where both a version string and a separate report/URL-list argument are provided. + /// is the version; is the filter source. + /// + private static async Task ResolveWithSeparateReportAsync( + IDiagnosticsCollector collector, + string profileName, + string profileArgument, + string profileReport, + BundleProfile profile, + IFileSystem fileSystem, + ILogger? logger, + Cancel ctx) + { + // profileArgument must be a version string, not a file/URL + var argType = PromotionReportParser.DetectArgumentType(profileArgument); + if (argType == ProfileArgumentType.PromotionReportUrl || + (argType == ProfileArgumentType.Version && fileSystem.File.Exists(profileArgument))) + { + collector.EmitError( + string.Empty, + "When two arguments are provided, the first must be a version string and the second must be a promotion report or URL list file. " + + $"'{profileArgument}' looks like a report path or URL. " + + $"Did you mean: {profileName} {profileArgument}?" + ); + return null; + } + + // A products pattern is mutually exclusive with a report/URL-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." + ); + return null; + } + + var version = profileArgument; + + // Detect the type of the report argument + var reportArgType = PromotionReportParser.DetectArgumentType(profileReport); + if (reportArgType == ProfileArgumentType.Version && fileSystem.File.Exists(profileReport)) + { + var ext = fileSystem.Path.GetExtension(profileReport).ToLowerInvariant(); + reportArgType = ext is ".html" or ".htm" + ? ProfileArgumentType.PromotionReportFile + : ProfileArgumentType.UrlListFile; + } + + switch (reportArgType) + { + case ProfileArgumentType.PromotionReportUrl: + case ProfileArgumentType.PromotionReportFile: + { + var parser = new PromotionReportParser(NullLoggerFactory.Instance, fileSystem); + var reportResult = await parser.ParsePromotionReportAsync(profileReport, ctx); + + if (!reportResult.IsValid) + { + collector.EmitError(string.Empty, reportResult.ErrorMessage ?? "Failed to parse promotion report"); + return null; + } + + var prs = reportResult.PrUrls.ToArray(); + logger?.LogInformation("Extracted {Count} PRs from promotion report", prs.Length); + return new ProfileFilterResult { Prs = prs, Version = version }; + } + case ProfileArgumentType.UrlListFile: + { + var result = await ResolveUrlListFileAsync(collector, profileReport, fileSystem, ctx); + if (result == null) + return null; + + return result.Value.Prs != null + ? new ProfileFilterResult { Prs = result.Value.Prs, Version = version } + : new ProfileFilterResult { Issues = result.Value.Issues, 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." + ); + return 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<(string[]? Prs, string[]? Issues)?> ResolveUrlListFileAsync( + IDiagnosticsCollector collector, + string filePath, + IFileSystem fileSystem, + Cancel ctx) + { + var content = await fileSystem.File.ReadAllTextAsync(filePath, ctx); + var lines = content + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(l => !string.IsNullOrWhiteSpace(l)) + .ToArray(); + + if (lines.Length == 0) + { + collector.EmitError(filePath, "URL list file is empty"); + return null; + } + + var hasPrs = false; + var hasIssues = false; + + foreach (var line in lines) + { + if (!line.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && + !line.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + { + 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). " + + $"Not a recognized URL: {line}" + ); + return null; + } + } + + if (hasPrs && hasIssues) + { + collector.EmitError(filePath, "File must contain only pull request URLs or only issue URLs, not a mix."); + return null; + } + + return hasPrs ? (lines, null) : (null, lines); + } + /// /// Parses a products pattern like "elasticsearch 9.2.0 ga" or /// "cloud-serverless {version} *" (after placeholder substitution) into a diff --git a/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs b/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs index 2bfbf0112..431aa961e 100644 --- a/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs +++ b/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs @@ -153,7 +153,9 @@ public enum ProfileArgumentType Unknown, Version, PromotionReportUrl, - PromotionReportFile + PromotionReportFile, + /// A newline-delimited file containing fully-qualified GitHub PR or issue URLs. + UrlListFile } /// diff --git a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs index 69487afcd..d4f74c79e 100644 --- a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs +++ b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs @@ -406,15 +406,17 @@ private static BundleConfiguration ParseBundleConfiguration(BundleConfigurationY { profiles = yaml.Profiles.ToDictionary( kvp => kvp.Key, - kvp => new BundleProfile - { - Products = kvp.Value.Products, - Output = kvp.Value.Output, - OutputProducts = kvp.Value.OutputProducts, - Repo = kvp.Value.Repo, - Owner = kvp.Value.Owner, - HideFeatures = kvp.Value.HideFeatures?.Values - }); + kvp => kvp.Value is null + ? new BundleProfile() + : new BundleProfile + { + Products = kvp.Value.Products, + Output = kvp.Value.Output, + OutputProducts = kvp.Value.OutputProducts, + Repo = kvp.Value.Repo, + Owner = kvp.Value.Owner, + HideFeatures = kvp.Value.HideFeatures?.Values + }); } return new BundleConfiguration diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 4f1a63fbb..b9c551f48 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -395,17 +395,19 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// /// Optional: Profile name from bundle.profiles in config (e.g., "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 (e.g., "9.2.0" or "https://buildkite.../promotion-report.html") - /// Include all changelogs in the directory. Only one filter option can be specified: `--all`, `--input-products`, `--prs`, or `--issues`. + /// 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 (e.g., "bundle serverless-release 2026-02 ./report.html"). + /// Include all changelogs in the directory. Only one filter option can be specified: `--all`, `--input-products`, `--prs`, `--issues`, or `--report`. /// 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 /// Filter by feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. Entries with matching feature-id values will be commented out when the bundle is rendered (by CLI render or {changelog} directive). - /// Filter by products in format "product target lifecycle, ..." (e.g., "cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta"). When specified, all three parts (product, target, lifecycle) are required but can be wildcards (*). Examples: "elasticsearch * *" matches all elasticsearch changelogs, "cloud-serverless 2025-12-02 *" matches cloud-serverless 2025-12-02 with any lifecycle, "* 9.3.* *" matches any product with target starting with "9.3.", "* * *" matches all changelogs (equivalent to --all). Only one filter option can be specified: `--all`, `--input-products`, `--prs`, or `--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. Each occurrence can be either comma-separated issues (e.g., `--issues "https://github.com/owner/repo/issues/123,456"`) or a file path (e.g., `--issues /path/to/file.txt`). When specifying issues directly, provide comma-separated values. When specifying a file path, provide a single value that points to a newline-delimited file. Only one filter option can be specified: `--all`, `--input-products`, `--prs`, or `--issues`. + /// Filter by products in format "product target lifecycle, ..." (e.g., "cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta"). When specified, all three parts (product, target, lifecycle) are required but can be wildcards (*). Examples: "elasticsearch * *" matches all elasticsearch changelogs, "cloud-serverless 2025-12-02 *" matches cloud-serverless 2025-12-02 with any lifecycle, "* 9.3.* *" matches any product with target starting with "9.3.", "* * *" matches all changelogs (equivalent to --all). Only one filter option can be specified: `--all`, `--input-products`, `--prs`, `--issues`, or `--report`. + /// 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. Only one filter option can be specified: `--all`, `--input-products`, `--prs`, `--issues`, or `--report`. /// Optional: Output path for the bundled changelog. Can be either (1) a directory path, in which case 'changelog-bundle.yaml' is created in that directory, or (2) a file path ending in .yml or .yaml. Uses config bundle.output_directory or defaults to 'changelog-bundle.yaml' in the input directory /// Optional: Explicitly set the products array in the output file in format "product target lifecycle, ...". Overrides any values from changelogs. /// Optional: GitHub repository owner. Required when PRs or issues are specified as numbers. Falls back to bundle.owner in changelog.yml when not specified. - /// 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. Only one filter option can be specified: `--all`, `--input-products`, `--prs`, or `--issues`. + /// 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. Only one filter option can be specified: `--all`, `--input-products`, `--prs`, `--issues`, or `--report`. /// Optional: GitHub repository name. Required when PRs or issues are specified as numbers. Also sets the repo field in bundle product entries for correct PR/issue link generation. Falls back to bundle.repo in changelog.yml when not specified; if that 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, --input-products, --prs, and --issues. /// Optional: Copy the contents of each changelog file into the entries array. Uses config bundle.resolve or defaults to false. /// Optional: Explicitly turn off resolve (overrides config). /// @@ -413,6 +415,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st public async Task Bundle( [Argument] string? profile = null, [Argument] string? profileArg = null, + [Argument] string? profileReport = null, bool all = false, string? config = null, string? directory = null, @@ -424,6 +427,7 @@ public async Task Bundle( string? owner = null, string[]? prs = null, string? repo = null, + string? report = null, bool? resolve = null, bool noResolve = false, Cancel ctx = default @@ -491,6 +495,8 @@ public async Task Bundle( forbidden.Add("--prs"); if (allIssues.Count > 0) forbidden.Add("--issues"); + if (!string.IsNullOrWhiteSpace(report)) + forbidden.Add("--report"); if (!string.IsNullOrWhiteSpace(output)) forbidden.Add("--output"); if (!string.IsNullOrWhiteSpace(repo)) @@ -523,6 +529,19 @@ public async Task Bundle( } else { + // profileReport (3rd positional arg) is only valid in profile mode + if (!string.IsNullOrWhiteSpace(profileReport)) + { + collector.EmitError( + string.Empty, + "A third positional argument is only valid in profile mode (e.g., 'bundle my-profile 2026-02 ./report.html')." + ); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + // Raw mode: require exactly one filter option var specifiedFilters = new List(); if (all) @@ -533,10 +552,12 @@ public async Task Bundle( specifiedFilters.Add("--prs"); if (allIssues.Count > 0) specifiedFilters.Add("--issues"); + 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, 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, or use a profile (e.g., 'bundle elasticsearch-release 9.2.0')"); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -545,7 +566,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, or --issues"); + 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.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -673,6 +694,8 @@ public async Task Bundle( Repo = repo, Profile = profile, ProfileArgument = profileArg, + ProfileReport = isProfileMode ? profileReport : null, + Report = !isProfileMode ? report : null, Config = config, HideFeatures = allFeatureIdsForBundle.Count > 0 ? allFeatureIdsForBundle.ToArray() : null }; @@ -689,24 +712,27 @@ async static (s, collector, state, ctx) => await s.BundleChangelogs(collector, s /// When a file is referenced by an unresolved bundle, the command blocks by default to prevent breaking /// the {changelog} directive. Use --force to override. /// - /// Optional: Profile name from bundle.profiles in config (e.g., "elasticsearch-release"). When specified, the second argument is the version or promotion report URL. Mutually exclusive with --all, --products, --prs, and --issues. + /// Optional: Profile name from bundle.profiles in config (e.g., "elasticsearch-release"). When specified, the second argument is the version or promotion report URL. Mutually exclusive with --all, --products, --prs, --issues, and --report. /// Optional: Version number or promotion report URL/path when using a profile (e.g., "9.2.0" or "https://buildkite.../promotion-report.html") - /// Remove all changelogs in the directory. Exactly one filter option must be specified: --all, --products, --prs, or --issues. + /// 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: 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 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. + /// 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. Exactly one filter option must be specified: --all, --products, --prs, --issues, or --report. /// Optional: GitHub repository owner. Required when PRs or issues are specified as numbers. Falls back to bundle.owner in changelog.yml when not specified. - /// 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. + /// 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, --issues, or --report. + /// 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. Exactly one filter option must be specified: --all, --products, --prs, --issues, or --report. /// Optional: GitHub repository name. Required when PRs or issues are specified as numbers. Falls back to bundle.repo in changelog.yml when not specified. + /// 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, and --issues. /// [Command("remove")] public async Task Remove( [Argument] string? profile = null, [Argument] string? profileArg = null, + [Argument] string? profileReport = null, bool all = false, string? bundlesDir = null, string? config = null, @@ -718,6 +744,7 @@ public async Task Remove( [ProductInfoParser] List? products = null, string[]? prs = null, string? repo = null, + string? report = null, Cancel ctx = default ) { @@ -765,6 +792,8 @@ public async Task Remove( forbidden.Add("--prs"); if (allIssues.Count > 0) forbidden.Add("--issues"); + if (!string.IsNullOrWhiteSpace(report)) + forbidden.Add("--report"); if (!string.IsNullOrWhiteSpace(repo)) forbidden.Add("--repo"); if (!string.IsNullOrWhiteSpace(owner)) @@ -802,6 +831,19 @@ public async Task Remove( } else { + // profileReport (3rd positional arg) is only valid in profile mode + if (!string.IsNullOrWhiteSpace(profileReport)) + { + collector.EmitError( + string.Empty, + "A third positional argument is only valid in profile mode (e.g., 'remove my-profile 2026-02 ./report.html')." + ); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + // Raw mode: validate product filter parts and apply wildcard shortcut if (products is { Count: > 0 }) { @@ -867,7 +909,9 @@ public async Task Remove( Force = force, Config = string.IsNullOrWhiteSpace(config) ? null : NormalizePath(config), Profile = isProfileMode ? profile : null, - ProfileArgument = isProfileMode ? profileArg : null + ProfileArgument = isProfileMode ? profileArg : null, + ProfileReport = isProfileMode ? profileReport : null, + Report = !isProfileMode ? report : null }; serviceInvoker.AddCommand(service, input, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index e0f1e9498..52534fd91 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -3313,6 +3313,656 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtDocsSubdir_LoadsSucce cwdFs.Directory.GetFiles("/test-root/output", "*.yaml").Should().NotBeEmpty("Expected output file to be created"); } + // ─── Phase 3: URL list file and combined version+report ───────────────────────────── + + [Fact] + public async Task BundleChangelogs_WithProfile_UrlListFile_PrUrls_FiltersCorrectly() + { + // Arrange - profile argument is a text file containing fully-qualified PR URLs + var configContent = + """ + bundle: + profiles: + release: + output: "bundle.yaml" + """; + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelog1 = + """ + title: Matched PR + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + // language=yaml + var changelog2 = + """ + title: Unmatched PR + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/999 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-matched.yaml"); + var file2 = FileSystem.Path.Combine(_changelogDir, "1755268140-unmatched.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); + + var urlFile = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); + await FileSystem.File.WriteAllTextAsync( + urlFile, + "https://github.com/elastic/elasticsearch/pull/100\n", + TestContext.Current.CancellationToken + ); + + // Profile writes to _changelogDir/bundle.yaml (output: "bundle.yaml" + no output_directory in config) + var expectedOutputPath = FileSystem.Path.Combine(_changelogDir, "bundle.yaml"); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Config = configPath, + Profile = "release", + ProfileArgument = urlFile + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var bundleContent = await FileSystem.File.ReadAllTextAsync(expectedOutputPath, TestContext.Current.CancellationToken); + bundleContent.Should().Contain("1755268130-matched.yaml"); + bundleContent.Should().NotContain("1755268140-unmatched.yaml"); + } + + [Fact] + public async Task BundleChangelogs_WithProfile_UrlListFile_IssueUrls_FiltersCorrectly() + { + // Arrange - profile argument is a text file containing fully-qualified issue URLs + var configContent = + """ + bundle: + profiles: + release: + output: "bundle.yaml" + """; + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelog1 = + """ + title: Matched Issue + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + issues: + - https://github.com/elastic/elasticsearch/issues/100 + """; + // language=yaml + var changelog2 = + """ + title: Unmatched Issue + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + issues: + - https://github.com/elastic/elasticsearch/issues/999 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-matched.yaml"); + var file2 = FileSystem.Path.Combine(_changelogDir, "1755268140-unmatched.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); + + var urlFile = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "issues.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); + await FileSystem.File.WriteAllTextAsync( + urlFile, + "https://github.com/elastic/elasticsearch/issues/100\n", + TestContext.Current.CancellationToken + ); + + // Profile writes to _changelogDir/bundle.yaml (output: "bundle.yaml" + no output_directory in config) + var expectedOutputPath = FileSystem.Path.Combine(_changelogDir, "bundle.yaml"); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Config = configPath, + Profile = "release", + ProfileArgument = urlFile + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var bundleContent = await FileSystem.File.ReadAllTextAsync(expectedOutputPath, TestContext.Current.CancellationToken); + bundleContent.Should().Contain("1755268130-matched.yaml"); + bundleContent.Should().NotContain("1755268140-unmatched.yaml"); + } + + [Fact] + public async Task BundleChangelogs_WithProfile_UrlListFile_Numbers_ReturnsError() + { + // Arrange - file contains bare PR numbers (not fully-qualified URLs) + var configContent = + """ + bundle: + profiles: + release: + output: "bundle.yaml" + """; + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var changelogFile = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, + """ + title: Feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """, TestContext.Current.CancellationToken); + + var urlFile = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); + await FileSystem.File.WriteAllTextAsync(urlFile, "100\n200\n", TestContext.Current.CancellationToken); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Config = configPath, + Profile = "release", + ProfileArgument = urlFile + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse("Should fail when file contains bare numbers"); + Collector.Errors.Should().BeGreaterThan(0); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && + d.Message.Contains("fully-qualified GitHub URLs"), + "Error should mention fully-qualified URLs requirement" + ); + } + + [Fact] + public async Task BundleChangelogs_WithProfile_UrlListFile_MixedPrsAndIssues_ReturnsError() + { + // Arrange - file contains both PR and issue URLs + var configContent = + """ + bundle: + profiles: + release: + output: "bundle.yaml" + """; + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var changelogFile = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, + """ + title: Feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """, TestContext.Current.CancellationToken); + + var urlFile = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "mixed.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); + await FileSystem.File.WriteAllTextAsync( + urlFile, + "https://github.com/elastic/elasticsearch/pull/100\nhttps://github.com/elastic/elasticsearch/issues/200\n", + TestContext.Current.CancellationToken + ); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Config = configPath, + Profile = "release", + ProfileArgument = urlFile + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse("Should fail when file mixes PR and issue URLs"); + Collector.Errors.Should().BeGreaterThan(0); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && + d.Message.Contains("only pull request URLs or only issue URLs"), + "Error should mention homogeneous URL requirement" + ); + } + + [Fact] + public async Task BundleChangelogs_WithProfile_CombinedVersionAndReport_SubstitutesVersionCorrectly() + { + // Arrange - version + report: version used for {version} substitution; report used for PR filter + var configContent = + """ + bundle: + profiles: + serverless-release: + output_products: "cloud-serverless {version}" + output: "serverless-{version}.yaml" + """; + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelog1 = + """ + title: Serverless Feb feature + type: feature + products: + - product: cloud-serverless + target: 2026-02-01 + lifecycle: ga + prs: + - https://github.com/elastic/cloud/pull/100 + """; + // language=yaml + var changelog2 = + """ + title: Unmatched PR + type: feature + products: + - product: cloud-serverless + target: 2026-02-02 + lifecycle: ga + prs: + - https://github.com/elastic/cloud/pull/999 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-feb.yaml"); + var file2 = FileSystem.Path.Combine(_changelogDir, "1755268140-other.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); + + var urlFile = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); + await FileSystem.File.WriteAllTextAsync( + urlFile, + "https://github.com/elastic/cloud/pull/100\n", + TestContext.Current.CancellationToken + ); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Config = configPath, + Profile = "serverless-release", + ProfileArgument = "2026-02", // version string + ProfileReport = urlFile, // URL list file (Phase 3.4) + OutputDirectory = outputDir + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + outputFiles.Should().NotBeEmpty(); + + // Output file name should use the version (not "unknown") + outputFiles[0].Should().Contain("2026-02", "Output file path should contain the version string"); + + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); + // Only the matched PR should be bundled + bundleContent.Should().Contain("1755268130-feb.yaml"); + bundleContent.Should().NotContain("1755268140-other.yaml"); + // Output products should contain the version + bundleContent.Should().Contain("cloud-serverless"); + bundleContent.Should().Contain("2026-02"); + } + + [Fact] + public async Task BundleChangelogs_WithProfile_CombinedVersion_ReportArgLooksLikeVersion_ReturnsError() + { + // If the first profile arg looks like a report but a second arg is also provided, error + var configContent = + """ + bundle: + profiles: + serverless-release: + output: "serverless-{version}.yaml" + """; + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + // A "fake" HTML file to act as the profile arg (simulating user accidentally reversing the order) + var reportFile = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "report.html"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(reportFile)!); + await FileSystem.File.WriteAllTextAsync(reportFile, "", TestContext.Current.CancellationToken); + + var urlFile = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); + await FileSystem.File.WriteAllTextAsync(urlFile, "https://github.com/elastic/cloud/pull/100\n", TestContext.Current.CancellationToken); + + // Act: profileArg is a file (should be version), profileReport is a URL file — report arg and version arg are swapped + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Config = configPath, + Profile = "serverless-release", + ProfileArgument = reportFile, // wrong — this looks like a file, should be a version + ProfileReport = urlFile + }; + + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse("Should fail when first arg looks like a report"); + Collector.Errors.Should().BeGreaterThan(0); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && + d.Message.Contains("version string"), + "Error should mention that the first arg should be the version" + ); + } + + [Fact] + public async Task BundleChangelogs_WithProfile_CombinedVersion_ProfileHasProducts_ReturnsError() + { + // A profile with a products pattern cannot also use a report/URL-file filter + var configContent = + """ + bundle: + profiles: + release: + products: "elasticsearch 9.2.0 ga" + output: "bundle.yaml" + """; + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var urlFile = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); + await FileSystem.File.WriteAllTextAsync(urlFile, "https://github.com/elastic/elasticsearch/pull/100\n", TestContext.Current.CancellationToken); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Config = configPath, + Profile = "release", + ProfileArgument = "9.2.0", + ProfileReport = urlFile + }; + + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse("Should fail when profile has products pattern and a report is also provided"); + Collector.Errors.Should().BeGreaterThan(0); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && + d.Message.Contains("products"), + "Error should mention the products pattern conflict" + ); + } + + // ─── Phase 4: --report option (option-based mode) ───────────────────────────────── + + [Fact] + public async Task BundleChangelogs_WithReportOption_ParsesPromotionReportAndFilters() + { + // Arrange - option-based mode with --report pointing to an HTML-like file + var htmlReportContent = + """ + + PR #100 + PR #200 + + """; + var reportFile = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "report.html"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(reportFile)!); + await FileSystem.File.WriteAllTextAsync(reportFile, htmlReportContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelog1 = + """ + title: Matched PR 100 + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + // language=yaml + var changelog2 = + """ + title: Unmatched PR 999 + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/999 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-matched.yaml"); + var file2 = FileSystem.Path.Combine(_changelogDir, "1755268140-unmatched.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); + + var outputPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Report = reportFile, + Output = outputPath + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); + bundleContent.Should().Contain("1755268130-matched.yaml"); + bundleContent.Should().NotContain("1755268140-unmatched.yaml"); + } + + [Fact] + public async Task BundleChangelogs_WithReportOption_FileNotFound_ReturnsError() + { + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Report = "/nonexistent/path/report.html" + }; + + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse("Should fail when report file does not exist"); + Collector.Errors.Should().BeGreaterThan(0); + } + + // ─── Phase 4.2: --prs and --issues file URL validation ─────────────────────────── + + [Fact] + public async Task BundleChangelogs_WithPrsFile_ContainingNumbers_ReturnsError() + { + // Arrange - prs file contains bare numbers (not fully-qualified URLs) + var prsFile = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(prsFile)!); + await FileSystem.File.WriteAllTextAsync(prsFile, "100\n200\n", TestContext.Current.CancellationToken); + + var changelogFile = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, + """ + title: Feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """, TestContext.Current.CancellationToken); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Prs = [prsFile], + Output = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), "bundle.yaml") + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse("Should fail when prs file contains bare numbers"); + Collector.Errors.Should().BeGreaterThan(0); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && + d.Message.Contains("fully-qualified GitHub URLs"), + "Error should mention fully-qualified URL requirement" + ); + } + + [Fact] + public async Task BundleChangelogs_WithIssuesFile_ContainingShortForms_ReturnsError() + { + // Arrange - issues file contains short forms (not fully-qualified URLs) + var issuesFile = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "issues.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(issuesFile)!); + await FileSystem.File.WriteAllTextAsync(issuesFile, "elastic/elasticsearch#100\n", TestContext.Current.CancellationToken); + + var changelogFile = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, + """ + title: Feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + issues: + - https://github.com/elastic/elasticsearch/issues/100 + """, TestContext.Current.CancellationToken); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Issues = [issuesFile], + Output = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), "bundle.yaml") + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse("Should fail when issues file contains short forms"); + Collector.Errors.Should().BeGreaterThan(0); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && + d.Message.Contains("fully-qualified GitHub URLs"), + "Error should mention fully-qualified URL requirement" + ); + } + + [Fact] + public async Task BundleChangelogs_WithPrsFile_ContainingValidUrls_FiltersCorrectly() + { + // Verify that a prs file with valid fully-qualified URLs still works correctly + var prsFile = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(prsFile)!); + await FileSystem.File.WriteAllTextAsync( + prsFile, + "https://github.com/elastic/elasticsearch/pull/100\n", + TestContext.Current.CancellationToken + ); + + // language=yaml + var changelog = + """ + title: Matched Feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + var file = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file, changelog, TestContext.Current.CancellationToken); + + var outputPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Prs = [prsFile], + Output = outputPath + }; + + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + } + private static string ExtractChecksum(string bundleContent) { var lines = bundleContent.Split('\n'); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs index 00e2d07cb..cfc36bcf4 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs @@ -673,4 +673,122 @@ public async Task Remove_WithProfile_NoProductsAndVersionArg_ReturnsSpecificErro d.Message.Contains("no-products-profile") && d.Message.Contains("no 'products' pattern")); } + + // ─── Phase 3: URL list file support for remove ────────────────────────────────── + + [Fact] + public async Task Remove_WithProfile_UrlListFile_PrUrls_RemovesMatchedFiles() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + await WriteFile("2001-kibana-feature.yaml", KibanaFeatureYaml); + + // language=yaml + var configContent = + """ + bundle: + profiles: + release: + """; + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + // URL file contains only the ES PR + var urlFile = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); + await FileSystem.File.WriteAllTextAsync( + urlFile, + "https://github.com/elastic/elasticsearch/pull/1001\n", + TestContext.Current.CancellationToken + ); + + var input = new ChangelogRemoveArguments + { + Directory = _changelogDir, + Profile = "release", + ProfileArgument = urlFile, + Config = configPath, + DryRun = true + }; + + var result = await ServiceWithConfig.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + // Dry-run: files still exist but the matched one should have been identified + FileSystem.File.Exists(FileSystem.Path.Combine(_changelogDir, "1001-es-feature.yaml")).Should().BeTrue("dry-run should not delete files"); + } + + [Fact] + public async Task Remove_WithProfile_CombinedVersionAndReport_UsesReportForFiltering() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + await WriteFile("2001-kibana-feature.yaml", KibanaFeatureYaml); + + // language=yaml + var configContent = + """ + bundle: + profiles: + release: + """; + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var urlFile = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); + await FileSystem.File.WriteAllTextAsync( + urlFile, + "https://github.com/elastic/elasticsearch/pull/1001\n", + TestContext.Current.CancellationToken + ); + + var input = new ChangelogRemoveArguments + { + Directory = _changelogDir, + Profile = "release", + ProfileArgument = "9.3.0", // version string + ProfileReport = urlFile, // URL list file (Phase 3.4) + Config = configPath, + DryRun = true + }; + + var result = await ServiceWithConfig.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + } + + // ─── Phase 4: --report option for option-based remove ──────────────────────────── + + [Fact] + public async Task Remove_WithReportOption_ParsesPromotionReportAndFilters() + { + await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); + await WriteFile("2001-kibana-feature.yaml", KibanaFeatureYaml); + + var htmlReport = + """ + + PR 1001 + + """; + var reportFile = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "report.html"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(reportFile)!); + await FileSystem.File.WriteAllTextAsync(reportFile, htmlReport, TestContext.Current.CancellationToken); + + var input = new ChangelogRemoveArguments + { + Directory = _changelogDir, + Report = reportFile, + DryRun = true + }; + + var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + } } From 7f2e49d5115ec42bbf76506b6617e28af2219e05 Mon Sep 17 00:00:00 2001 From: lcawl Date: Thu, 26 Feb 2026 19:35:20 -0800 Subject: [PATCH 2/2] Fix path resolution and improve docs --- docs/cli/release/changelog-bundle.md | 38 +++++++++--- docs/cli/release/changelog-remove.md | 34 +++++++++-- docs/contribute/changelog.md | 58 ++++++++++++------- .../Bundling/ChangelogBundlingService.cs | 7 ++- .../docs-builder/Commands/ChangelogCommand.cs | 7 ++- .../Changelogs/BundleChangelogsTests.cs | 13 ++--- 6 files changed, 114 insertions(+), 43 deletions(-) diff --git a/docs/cli/release/changelog-bundle.md b/docs/cli/release/changelog-bundle.md index fb1028854..1aba91a82 100644 --- a/docs/cli/release/changelog-bundle.md +++ b/docs/cli/release/changelog-bundle.md @@ -67,7 +67,7 @@ Using any of them with a profile returns an error. `--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. +: When not specified, falls back to `bundle.directory` from the changelog configuration, then the current working directory. See [Output files](#output-files) for the full resolution order. `--hide-features ` : Optional: A list of feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. @@ -99,7 +99,7 @@ Using any of them with a profile returns an error. `--output ` : Optional: The output path for the bundle. : Can be either (1) a directory path, in which case `changelog-bundle.yaml` is created in that directory, or (2) a file path ending in `.yml` or `.yaml`. -: When not specified, uses `bundle.output_directory` from the changelog configuration (creating `changelog-bundle.yaml` in that directory) if set, otherwise `changelog-bundle.yaml` in the input directory. +: When not specified, falls back to `bundle.output_directory` from the changelog configuration, then the input directory (which is itself resolved from `--directory`, `bundle.directory`, or the current working directory). See [Output files](#output-files) for the full resolution order. `--output-products ?>` : Optional: Explicitly set the products array in the output file in format "product target lifecycle, ...". @@ -131,13 +131,30 @@ Using any of them with a profile returns an error. ## Output files -Profile-based bundles are created in `bundle.output_directory`. -If `output_directory` is not set, they are created in the `bundle.directory` alongside the changelog files. -Bundle names are determined by the `bundle.profiles..output` setting, which can optionally include additional profile-specific paths. For example: `"stack/kibana-{version}.yaml"`. -If that setting is absent, the default name is `changelog-bundle.yaml` +Both modes use the same ordered fallback to determine where to write the bundle. The first value that is set wins: -In the option-based mode, when you do not specify `--output`, the command uses `bundle.output_directory` or defaults to the input directory. -When you specify `--output`, it supports two formats: +**Output directory** (where the bundle file is placed): + +| Priority | Profile-based | Option-based | +|----------|---------------|--------------| +| 1 | — | `--output` (explicit file or directory path) | +| 2 | `bundle.output_directory` in `changelog.yml` | `bundle.output_directory` in `changelog.yml` | +| 3 | `bundle.directory` in `changelog.yml` | `--directory` CLI option | +| 4 | Current working directory | `bundle.directory` in `changelog.yml` | +| 5 | — | Current working directory | + +**Input directory** (where changelog YAML files are read from) follows the same fallback for both modes, minus the explicit CLI override that is forbidden in profile mode: + +| Priority | Profile-based | Option-based | +|----------|---------------|--------------| +| 1 | `bundle.directory` in `changelog.yml` | `--directory` CLI option | +| 2 | Current working directory | `bundle.directory` in `changelog.yml` | +| 3 | — | Current working directory | + +**Bundle filename** is determined by the `bundle.profiles..output` setting (profile-based) or defaults to `changelog-bundle.yaml` (both modes). +The profile `output` setting can include additional path segments. For example: `"stack/kibana-{version}.yaml"`. + +In option-based mode, when you specify `--output`, it supports two formats: 1. **Directory path**: If you specify a directory path (without a filename), the command creates `changelog-bundle.yaml` in that directory: @@ -155,6 +172,11 @@ When you specify `--output`, it supports two formats: If you specify a file path with a different extension (not `.yml` or `.yaml`), the command returns an error. +:::{note} +"Current working directory" means the directory you are in when you run the command (`pwd`). +Setting `bundle.directory` and `bundle.output_directory` in `changelog.yml` is recommended so you don't need to rely on running the command from a specific directory. +::: + ## Repository name in bundles [changelog-bundle-repo] The repository name is stored in each bundle product entry to ensure that PR and issue links are generated correctly when the bundle is rendered. diff --git a/docs/cli/release/changelog-remove.md b/docs/cli/release/changelog-remove.md index af40db6c2..bae4f34c8 100644 --- a/docs/cli/release/changelog-remove.md +++ b/docs/cli/release/changelog-remove.md @@ -48,8 +48,8 @@ These arguments apply to profile-based removal: `--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. -: Not allowed with a profile argument. In profile mode, the bundles directory is derived from `bundle.output_directory` in the changelog configuration. +: When not specified, the directory is resolved in order: `bundle.output_directory` from the changelog configuration, then `{changelog-dir}/bundles`, then `{changelog-dir}/../bundles`. +: Not allowed with a profile argument. In profile mode, the same automatic discovery applies. `--config ` : Optional: Path to the changelog configuration file. @@ -58,8 +58,8 @@ These arguments apply to profile-based removal: `--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. -: Not allowed with a profile argument. In profile mode, the directory is derived from `bundle.directory` in the changelog configuration. +: When not specified, falls back to `bundle.directory` from the changelog configuration, then the current working directory. +: Not allowed with a profile argument. In profile mode, the same fallback applies (starting from `bundle.directory`). `--dry-run` : Print the files that would be removed and any bundle dependency conflicts, without deleting anything. @@ -109,6 +109,32 @@ These arguments apply to profile-based removal: : Exactly one filter option must be specified: `--all`, `--products`, `--prs`, `--issues`, or `--report`. : Not allowed with a profile argument. +## Directory resolution [changelog-remove-dirs] + +Both modes use the same ordered fallback to locate changelog YAML files and existing bundles. + +**Changelog files directory** (where changelog YAML files are read from): + +| Priority | Profile-based | Option-based | +|----------|---------------|--------------| +| 1 | `bundle.directory` in `changelog.yml` | `--directory` CLI option | +| 2 | Current working directory | `bundle.directory` in `changelog.yml` | +| 3 | — | Current working directory | + +**Bundles directory** (scanned for existing bundles during the dependency check): + +| Priority | Both modes | +|----------|------------| +| 1 | `--bundles-dir` CLI option (option-based only) | +| 2 | `bundle.output_directory` in `changelog.yml` | +| 3 | `{changelog-dir}/bundles` | +| 4 | `{changelog-dir}/../bundles` | + +:::{note} +"Current working directory" means the directory you are in when you run the command (`pwd`). +Setting `bundle.directory` and `bundle.output_directory` in `changelog.yml` is recommended so you don't need to rely on running the command from a specific directory. +::: + ## Profile-based removal [changelog-remove-profile] When a `changelog.yml` configuration file defines `bundle.profiles`, you can use those same profiles with `changelog remove` to remove exactly the changelogs that would be included in a matching bundle. diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index 12a8a3aa7..5ccf8d56a 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -512,9 +512,12 @@ If you plan to use [changelog directives](#changelog-directive), it is recommend 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. ::: + ### Profile-based bundling [changelog-bundle-profile] @@ -524,16 +527,7 @@ If your `changelog.yml` configuration file defines `bundle.profiles`, you can ru docs-builder changelog bundle ``` -The second argument accepts a version string, a promotion report URL/path, or a URL list file (a plain-text file with one fully-qualified GitHub URL per line). When your profile uses `{version}` in its `output` or `output_products` pattern and you also want to filter by a report, pass both: - -```sh -# Version + report (version for substitution, report for filtering) -docs-builder changelog bundle serverless-release 2026-02 ./promotion-report.html - -# Version + URL list file -docs-builder changelog bundle serverless-release 2026-02 ./prs.txt -``` - +The second argument accepts a version string, a promotion report URL/path, or a URL list file (a plain-text file with one fully-qualified GitHub URL per line). When your profile uses `{version}` in its `output` or `output_products` pattern and you also want to filter by a report, pass both. For example: ```sh @@ -542,11 +536,14 @@ docs-builder changelog bundle elasticsearch-release ./promotion-report.html docs-builder changelog bundle elasticsearch-release 9.2.0 ./promotion-report.html ``` + Top-level `bundle` fields: @@ -561,7 +558,7 @@ Profile configuration fields in `bundle.profiles`: |---|---| | `products` | Product filter pattern with `{version}` and `{lifecycle}` placeholders. Used to match changelog files. | | `output` | Output file path pattern with `{version}` and `{lifecycle}` placeholders. | -| `output_products` | Optional override for the products array written to the bundle. Useful when the bundle should advertise a different lifecycle or version than the filter. | +| `output_products` | Optional override for the products array written to the bundle. Useful when the bundle should have a single product ID though it's filtered from many or have a different lifecycle or version than the filter. | | `repo` | Optional. Overrides `bundle.repo` for this profile only. | | `owner` | Optional. Overrides `bundle.owner` for this profile only. | | `hide_features` | List of feature IDs to embed in the bundle as hidden. | @@ -570,21 +567,22 @@ Example profile configuration: ```yaml bundle: - repo: cloud # default for all profiles - owner: elastic + repo: elasticsearch # The default repository for PR and issue links. + owner: elastic # The default repository owner for PR and issue links. + directory: docs/changelog # The directory that contains changelog files. + output_directory: docs/releases # The directory that contains changelog bundles. profiles: elasticsearch-release: products: "elasticsearch {version} {lifecycle}" - output: "bundles/elasticsearch-{version}.yaml" + output: "elasticsearch/{version}.yaml" output_products: "elasticsearch {version}" - repo: elasticsearch # overrides bundle.repo for this profile hide_features: - feature:experimental-api serverless-release: products: "cloud-serverless {version} *" - output: "bundles/serverless-{version}.yaml" + output: "serverless/{version}.yaml" output_products: "cloud-serverless {version}" - # inherits repo: cloud and owner: elastic from bundle level + # inherits repo: elasticsearch and owner: elastic from bundle level ``` ### Filter by product [changelog-bundle-product] @@ -593,6 +591,10 @@ You can use the `--input-products` option to create a bundle of changelogs that When using `--input-products`, you must provide all three parts: product, target, and lifecycle. Each part can be a wildcard (`*`) to match any value. +:::{tip} +If you use profile-based bundling, provide this information in the `bundle.profiles..products` field. +::: + ```sh docs-builder changelog bundle \ --input-products "cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta" <1> @@ -654,7 +656,6 @@ docs-builder changelog bundle --prs "108875,135873,136886" \ <1> --output-products "elasticsearch 9.2.2 ga" <4> ``` - 1. The comma-separated list of pull request numbers to seek. 2. The repository in the pull request URLs. Not required when using full PR URLs, or when `bundle.repo` is set in the changelog configuration. 3. The owner in the pull request URLs. Not required when using full PR URLs, or when `bundle.owner` is set in the changelog configuration. @@ -692,9 +693,15 @@ docs-builder changelog bundle --issues "12345,12346" \ --output-products "elasticsearch 9.2.2 ga" ``` -### Filter by pull request file [changelog-bundle-file] +### Filter by pull request or issue file [changelog-bundle-file] -If you have a file that lists pull requests (such as PRs associated with a GitHub release): +If you have a file that lists issue or pull requests (such as PRs associated with a GitHub release), you can use it to create bundles. + +:::{tip} +You can use these files with profile-based bundling too. Refer to [](/cli/release/changelog-bundle.md). +::: + +For example: ```txt https://github.com/elastic/elasticsearch/pull/108875 @@ -703,7 +710,10 @@ https://github.com/elastic/elasticsearch/pull/136886 https://github.com/elastic/elasticsearch/pull/137126 ``` -You can use the `--prs` option with a file path to create a bundle of the changelogs that relate to those pull requests. You can also combine multiple `--prs` options: + + +You can use the `--prs` option with a file path to create a bundle of the changelogs that relate to those pull requests. +You can also combine multiple `--prs` options: ```sh ./docs-builder changelog bundle \ @@ -757,6 +767,12 @@ docs-builder changelog bundle \ 1. Feature IDs to hide. Entries with matching `feature-id` values will be commented out when rendered. + + The bundle output will include a `hide-features` field: ```yaml diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index fe5714113..8f65b57b2 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -277,7 +277,12 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle var outputPattern = profile.Output?.Replace("{version}", filterResult.Version); if (!string.IsNullOrWhiteSpace(outputPattern)) { - var outputDir = config.Bundle.OutputDirectory ?? input.OutputDirectory ?? input.Directory ?? _fileSystem.Directory.GetCurrentDirectory(); + // Resolution order: bundle.output_directory → input.OutputDirectory (programmatic override) + // → bundle.directory → CWD + var outputDir = config.Bundle.OutputDirectory + ?? input.OutputDirectory + ?? config.Bundle.Directory + ?? _fileSystem.Directory.GetCurrentDirectory(); outputPath = _fileSystem.Path.Combine(outputDir, outputPattern); } diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index b9c551f48..3a3b41a7e 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -892,8 +892,11 @@ public async Task Remove( } // In profile mode, directory is derived from the changelog config (not from CLI). - // In raw mode, use --directory if provided, falling back to cwd (for the service's ApplyConfigDefaults). - var resolvedDirectory = isProfileMode ? null : NormalizePath(directory ?? Directory.GetCurrentDirectory()); + // In raw mode, pass null when --directory is not specified so ApplyConfigDefaults can consult + // bundle.directory before falling back to CWD. + var resolvedDirectory = isProfileMode || string.IsNullOrWhiteSpace(directory) + ? null + : NormalizePath(directory); var input = new ChangelogRemoveArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index 52534fd91..f9c5b1ebf 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -3319,9 +3319,9 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtDocsSubdir_LoadsSucce public async Task BundleChangelogs_WithProfile_UrlListFile_PrUrls_FiltersCorrectly() { // Arrange - profile argument is a text file containing fully-qualified PR URLs - var configContent = - """ + var configContent = $""" bundle: + directory: {_changelogDir} profiles: release: output: "bundle.yaml" @@ -3368,12 +3368,11 @@ await FileSystem.File.WriteAllTextAsync( TestContext.Current.CancellationToken ); - // Profile writes to _changelogDir/bundle.yaml (output: "bundle.yaml" + no output_directory in config) + // Profile writes to _changelogDir/bundle.yaml because bundle.directory is the fallback for output_directory var expectedOutputPath = FileSystem.Path.Combine(_changelogDir, "bundle.yaml"); var input = new BundleChangelogsArguments { - Directory = _changelogDir, Config = configPath, Profile = "release", ProfileArgument = urlFile @@ -3395,9 +3394,9 @@ await FileSystem.File.WriteAllTextAsync( public async Task BundleChangelogs_WithProfile_UrlListFile_IssueUrls_FiltersCorrectly() { // Arrange - profile argument is a text file containing fully-qualified issue URLs - var configContent = - """ + var configContent = $""" bundle: + directory: {_changelogDir} profiles: release: output: "bundle.yaml" @@ -3445,11 +3444,11 @@ await FileSystem.File.WriteAllTextAsync( ); // Profile writes to _changelogDir/bundle.yaml (output: "bundle.yaml" + no output_directory in config) + // Profile writes to _changelogDir/bundle.yaml because bundle.directory is the fallback for output_directory var expectedOutputPath = FileSystem.Path.Combine(_changelogDir, "bundle.yaml"); var input = new BundleChangelogsArguments { - Directory = _changelogDir, Config = configPath, Profile = "release", ProfileArgument = urlFile