diff --git a/config/changelog.example.yml b/config/changelog.example.yml index e677eef4b..06a151f07 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -169,23 +169,42 @@ bundle: output_directory: docs/releases # Whether to resolve (copy contents) by default resolve: true + # Optional: default GitHub repo name applied to all profiles that do not specify their own. + # Used by the {changelog} directive to generate correct PR/issue links when the product ID + # differs from the GitHub repository name. Can be overridden per profile. + # repo: elasticsearch + # Optional: default GitHub owner applied to all profiles that do not specify their own. + # owner: elastic # Named bundle profiles for different release scenarios. # Profiles can be used with both 'changelog bundle' and 'changelog remove': # docs-builder changelog bundle elasticsearch-release 9.2.0 # docs-builder changelog remove elasticsearch-release 9.2.0 # When used with 'changelog remove', only the 'products' field is applied. - # The 'output' and 'hide_features' fields are bundle-specific and are ignored for removal. + # The 'output', 'output_products', 'repo', 'owner', and 'hide_features' fields are + # bundle-specific and are ignored for removal. profiles: # Example: Elasticsearch release profile # elasticsearch-release: + # # Filter: which input changelogs to include ({version} and {lifecycle} are substituted at runtime) # products: "elasticsearch {version} {lifecycle}" + # # Output filename ({version} is substituted at runtime) # output: "elasticsearch-{version}.yaml" + # # Optional: override the products array written to the bundle output. + # # Useful when the bundle should advertise a different lifecycle than was used for filtering. + # # output_products: "elasticsearch {version} ga" + # # Optional: profile-specific GitHub repo name (overrides bundle.repo if set). + # # Only needed when this profile's product ID differs from the repository name. + # # repo: elasticsearch + # # Optional: profile-specific GitHub owner (overrides bundle.owner if set). + # # owner: elastic - # Example: Serverless release profile + # Example: Serverless release profile (product ID "cloud-serverless" differs from repo "cloud") # serverless-release: # products: "cloud-serverless {version} *" # output: "serverless-{version}.yaml" + # # repo overrides the bundle-level default for this profile only + # # repo: cloud # # Feature IDs to hide when bundling with this profile (accepts string or list) # hide_features: # - feature-flag-1 diff --git a/docs/cli/release/changelog-bundle.md b/docs/cli/release/changelog-bundle.md index 09402cfb6..82bc778a3 100644 --- a/docs/cli/release/changelog-bundle.md +++ b/docs/cli/release/changelog-bundle.md @@ -11,10 +11,42 @@ For details and examples, go to [](/contribute/changelog.md). docs-builder changelog bundle [arguments...] [options...] [-h|--help] ``` -## Bundle with command options +`changelog bundle` supports two mutually exclusive invocation modes: -This type of bundling ignores the `bundles.profiles` section of the changelog configuration file. -You must choose one method for determining what's in the bundle (`--all`, `--input-products`, `--prs`, or `--issues`). +- **Profile-based**: All paths and filters come from the changelog configuration file. No other options are allowed. For example, `bundle `. +- **Option-based**: You supply all filter and output options directly. For example, `bundle --all` (or `--input-products`, `--prs`, `--issues`). + +You cannot mix the two modes. Passing any option-based flag together with a profile returns an error. + +Profile-based commands discover the changelog configuration automatically (no `--config` flag): they look for `changelog.yml` in the current directory, then `docs/changelog.yml`. +If neither file is found, the command returns an error with instructions to run `docs-builder changelog init` or to re-run from the folder where the file exists. + +## Arguments + +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. + +`[1] ` +: Version number or promotion report URL or path. +: For example, "9.2.0" or `https://buildkite.../promotion-report.html`. + +:::{note} +Only the profile-based method currently supports buildkite promotion reports. +There is no equivalent command option. +::: + + + +## Options + +The following options are only valid in option-based mode (no profile argument). +Using any of them with a profile returns an error. `--all` : Include all changelogs from the directory. @@ -66,7 +98,8 @@ You must choose one method for determining what's in the bundle (`--all`, `--inp : This value replaces information that would otherwise by derived from changelogs. `--owner ` -: The GitHub repository owner, which is required when pull requests or issues are specified as numbers. +: Optional: The GitHub repository owner, required when pull requests or issues are specified as numbers. +: 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. @@ -76,16 +109,22 @@ You must choose one method for determining what's in the bundle (`--all`, `--inp : When specifying a file path, provide a single value that points to a newline-delimited file. `--repo ` -: The GitHub repository name, which is required when pull requests or issues are specified as numbers. +: Optional: The GitHub repository name, required when pull requests or issues are specified as numbers. +: Also sets the `repo` field in each bundle product entry 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. `--resolve` : Optional: Copy the contents of each changelog file into the entries array. : By default, the bundle contains only the file names and checksums. -### Output files +## Output files -When you do not specify `--output`, the command uses `bundle.output_directory` from your changelog configuration if it is set (creating `changelog-bundle.yaml` in that directory), otherwise writes to `changelog-bundle.yaml` in the input directory. +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` +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: 1. **Directory path**: If you specify a directory path (without a filename), the command creates `changelog-bundle.yaml` in that directory: @@ -104,21 +143,41 @@ 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. -### Repository name in bundles [changelog-bundle-repo] +## Repository name in bundles [changelog-bundle-repo] -When you specify the `--repo` option, the repository name is stored in the bundle's product metadata. -This ensures that PR and issue links are generated correctly when the bundle is rendered. +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. +It can be set in three ways, in order of precedence: -```sh -docs-builder changelog bundle \ - --input-products "cloud-serverless 2025-12-02 *" \ - --repo cloud \ <1> - --output /path/to/bundles/2025-12-02.yaml +1. **`--repo` option** (option-based mode only) +2. **`repo` field in the profile** (profile-based mode only; overrides the bundle-level default) +3. **`bundle.repo` in `changelog.yml`** (applies to both modes as a default when neither of the above is set) + +Setting `bundle.repo` and `bundle.owner` in your configuration means you rarely need to pass `--repo` and `--owner` on the command line: + +```yaml +bundle: + repo: cloud + owner: elastic ``` -1. The GitHub repository name. This is stored in each product entry in the bundle. +You can still override them per profile if a project has multiple products with different repos: -The bundle output will include a `repo` field in each product: +```yaml +bundle: + repo: cloud # default for all profiles + owner: elastic + profiles: + elasticsearch-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch-{version}.yaml" + repo: elasticsearch # overrides bundle.repo for this profile only + serverless-release: + products: "cloud-serverless {version} *" + output: "serverless-{version}.yaml" + # inherits repo: cloud from bundle level +``` + +The bundle output includes a `repo` field in each product: ```yaml products: @@ -131,86 +190,79 @@ entries: checksum: 6c3243f56279b1797b5dfff6c02ebf90b9658464 ``` -When rendering, pull request and issue links will use `https://github.com/elastic/cloud/...` instead of the product ID in the URL. +When rendering, pull request and issue links use `https://github.com/elastic/cloud/...` instead of the product ID. :::{note} -If the `repo` field is not specified, the product ID is used as a fallback for link generation. +If no `repo` is set at any level, the product ID is used as a fallback for link generation. This may result in broken links if the product ID doesn't match the GitHub repository name (for example, `cloud-serverless` vs `cloud`). ::: -## Bundle with a profile +## Profile configuration fields [changelog-bundle-profile-config] -This type of bundle is invoked by passing a profile name and filter as positional arguments: +Bundle profiles in `changelog.yml` support the following fields: -```sh -docs-builder changelog bundle -``` +`products` +: Required. The product filter pattern for input changelogs. Supports `{version}` and `{lifecycle}` placeholders that are substituted at runtime. +: Example: `"elasticsearch {version} {lifecycle}"` -The profile name must be defined in the `bundle.profiles` section of the changelog configuration file. -For example, "elasticsearch-release". +`output` +: Required for bundling. The output filename pattern. `{version}` is substituted at runtime. +: Example: `"elasticsearch-{version}.yaml"` -The second argument is the optional version number, promotion report URL, or path. -For example: +`output_products` +: Optional. Overrides the products array written to the bundle output. Supports `{version}` and `{lifecycle}` placeholders. Useful when the bundle should advertise a different lifecycle than was used for filtering — for example, when filtering by `preview` changelogs to produce a `ga` bundle. +: Example: `"elasticsearch {version} ga"` -```sh -# Bundle changelogs that match a specific version or date -docs-builder changelog bundle elasticsearch-release 9.2.0 +`repo` +: Optional. The GitHub repository name written to each product entry in the bundle. Used by the `{changelog}` directive to generate correct PR/issue links. Only needed when the product ID doesn't match the GitHub repository name. Overrides `bundle.repo` when set. +: Example: `repo: cloud` (for the `cloud-serverless` product) -# Bundle changelogs that match a list of PRs in a promotion report -docs-builder changelog bundle serverless-release https://buildkite.../promotion-report.html +`owner` +: Optional. The GitHub owner written to each product entry in the bundle. Overrides `bundle.owner` when set. +: Example: `owner: elastic` -# Bundle changelogs that match a list of PRs in a downloaded promotion report -docs-builder changelog bundle serverless-release ./promotion-report.html -``` - - +`hide_features` +: Optional. Feature IDs to mark as hidden in the bundle output (string or list). When the bundle is rendered, entries with matching `feature-id` values are commented out. -:::{note} -This is the only method that currently supports the creation of bundles from buildkite promotion reports. -It can not be accomplished by using command options. -::: - -### Output files [profile-output] - -The location of profile-based bundles is determined by the changelog configuration file. - -Bundles are created in the `bundle.output_directory`, if that setting exists. -Otherwise, they're created in `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` - -### Examples [profile-examples] +## Examples The following changelog configuration example contains multiple profiles for filtering the bundles: ```yaml bundle: + repo: cloud <1> + owner: elastic profiles: - # Hardcode a specific lifecycle + # Find changelogs with a specific lifecycle elasticsearch-ga-only: - products: "elasticsearch {version} ga" <1> + products: "elasticsearch {version} ga" <2> output: "elasticsearch-{version}.yaml" - - # Match any lifecycle (wildcard) - serverless-release: - products: "cloud-serverless {version} *" <2> + repo: elasticsearch <3> + + # Find changelogs with any lifecycle and a partial date + serverless-monthly: + products: "cloud-serverless {version}-* *" <4> output: "serverless-{version}.yaml" - + output_products: "cloud-serverless {version}" + # repo and owner inherited from bundle level + + # Infer the lifecycle from the version elasticsearch-release: - hide_features: <3> + hide_features: <5> - feature-flag-1 - feature-flag-2 - products: "elasticsearch {version} {lifecycle}" <4> + products: "elasticsearch {version} {lifecycle}" <6> output: "elasticsearch-{version}.yaml" + output_products: "elasticsearch {version}" + repo: elasticsearch <3> ``` -1. Bundles any changelogs that have `product: elasticsearch`, `lifecycle: ga`, and the version specified in the command. This is equivalent to the `--input-products` command option. -2. Bundles any changelogs that have `product: cloud-serverless`, any lifecycle, and the version specified in the command. This is equivalent to the `--input-products` command option's support for wildcards. -3. Adds a `hide-features` array in the bundle. This is equivalent to the `--hide-features` command option. -4. In this case, the lifecycle is inferred from the version. +1. Bundle-level defaults that apply to all profiles. Individual profiles can override these. +2. Bundles any changelogs that have `product: elasticsearch`, `lifecycle: ga`, and the version specified in the command. This is equivalent to the `--input-products` command option. +3. Overrides the bundle-level `repo: cloud` for this profile because the `elasticsearch` product matches its GitHub repository name. +4. Bundles any changelogs that have `product: cloud-serverless`, any lifecycle, and the date partially specified in the command. This is equivalent to the `--input-products` command option's support for wildcards. +5. Adds a `hide-features` array in the bundle. This is equivalent to the `--hide-features` command option. +6. In this case, the lifecycle is inferred from the version. For example, when the version is: @@ -219,3 +271,21 @@ For example, when the version is: - `9.2.0-alpha.1` or `9.2.0-preview.1` the inferred lifecycle is `preview`. For more information about acceptable product and lifecycle values, go to [Product format](/contribute/changelog.md#product-format). + +You can invoke those profiles with commands like this: + +```sh +# Bundle changelogs that match a specific version or date +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 eae8448dd..95437a703 100644 --- a/docs/cli/release/changelog-remove.md +++ b/docs/cli/release/changelog-remove.md @@ -5,7 +5,9 @@ Remove changelog YAML files from a directory. You can use either profile-based removal or raw filter flags: - **Profile-based**: `docs-builder changelog remove ` — uses the same `bundle.profiles` configuration as [`changelog bundle`](/cli/release/changelog-bundle.md) to determine which changelogs to remove. -- **Raw flags**: `docs-builder changelog remove --products "..." ` (or `--prs`, `--issues`, `--all`) — specify the filter directly. +- **Option-based**: `docs-builder changelog remove --products "..." ` (or `--prs`, `--issues`, `--all`) — specify the filter directly. + +These modes are mutually exclusive. You can't combine a profile argument with option-based flags. Before deleting anything, the command checks whether any of the matching files are referenced by unresolved bundles, to prevent silently breaking the `{changelog}` directive. @@ -19,14 +21,13 @@ docs-builder changelog remove [arguments...] [options...] [-h|--help] ## Arguments -You can use either profile-based removal (for example, `remove elasticsearch-release 9.2.0`) or raw flags (`remove --all`). 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`, and `--issues`. +: Mutually exclusive with `--all`, `--products`, `--prs`, `--issues`, `--owner`, `--repo`, `--config`, `--directory`, and `--bundles-dir`. `[1] ` : Version number or promotion report URL or path. @@ -37,42 +38,46 @@ These arguments apply to profile-based removal: `--all` : Remove all changelog files in the directory. : Exactly one filter option must be specified: `--all`, `--products`, `--prs`, or `--issues`. -: Cannot be combined with a profile argument. +: Not allowed with a profile argument. `--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. `--config ` : Optional: Path to the changelog configuration file. : Defaults to `docs/changelog.yml`. +: Not allowed with a profile argument. In profile mode, the configuration is discovered automatically. `--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. `--dry-run` : Print the files that would be removed and any bundle dependency conflicts, without deleting anything. -: Valid in both profile and raw mode. +: Valid in both profile and option-based mode. `--force` : Proceed with removal even when files are referenced by unresolved bundles. : Emits a warning per dependency instead of blocking. -: Valid in both profile and raw mode. +: 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. : Can be specified multiple times. : Exactly one filter option must be specified: `--all`, `--products`, `--prs`, or `--issues`. -: Cannot be combined with a profile argument. +: Not allowed with a profile argument. `--owner ` : The GitHub repository owner, which is required when pull requests or issues are specified as numbers. +: Not allowed with a profile argument. `--products ?>` : Filter by products in format `"product target lifecycle, ..."` : Exactly one filter option must be specified: `--all`, `--products`, `--prs`, or `--issues`. -: Cannot be combined with a profile argument. +: Not allowed with a profile argument. : All three parts (product, target, lifecycle) are required but can be wildcards (`*`). Multiple comma-separated values are combined with OR: a changelog is removed if it matches any of the specified product/target/lifecycle combinations. For example: - `"elasticsearch 9.3.0 ga"` — exact match @@ -85,16 +90,19 @@ These arguments apply to profile-based removal: : Filter by pull request URLs or numbers (comma-separated), or a path to a newline-delimited file containing PR URLs or numbers. : Can be specified multiple times. : Exactly one filter option must be specified: `--all`, `--products`, `--prs`, or `--issues`. -: Cannot be combined with a profile argument. +: 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. ## 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. -Only the `products` field from a profile is used for removal. The `output` and `hide_features` fields are bundle-specific and are ignored. +Profile-based commands discover the changelog configuration automatically (no `--config` flag): they look for `changelog.yml` in the current directory, then `docs/changelog.yml`. If neither file is found, the command returns an error with instructions to run `docs-builder changelog init` or to re-run from the folder where the file exists. + +Only the `products` field from a profile is used for removal. The `output`, `output_products`, `repo`, `owner`, and `hide_features` fields are bundle-specific and are ignored. For example, if your configuration file defines: diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index c22b51a5e..7d3f4b434 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -487,7 +487,16 @@ The command has two modes of operation: you can specify all the command options The latter is more convenient and consistent for repetitive workflows. For up-to-date details, use the `-h` option or refer to [](/cli/release/changelog-bundle.md). -If you're not using profiles, you must choose one of the following filter options: +The command supports two mutually exclusive usage modes: + +- **Option-based** — you provide filter and output options directly on the command line. +- **Profile-based** — you specify a named profile from your `changelog.yml` configuration file. + +You cannot mix these two modes: when you use a profile name, no filter or output options are accepted on the command line. + +### Option-based bundling [changelog-bundle-options] + +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). @@ -504,6 +513,67 @@ If you likewise want to regenerate your [Asciidoc or Markdown files](#render-cha When you do not specify `--directory`, the command reads changelog files from `bundle.directory` in your changelog configuration if it is set, otherwise from the current directory. When you do not specify `--output`, the command writes the bundle to `bundle.output_directory` from your changelog configuration (creating `changelog-bundle.yaml` in that directory) if it is set, otherwise to `changelog-bundle.yaml` in the input directory. +When you do not specify `--repo` or `--owner`, the command falls back to `bundle.repo` and `bundle.owner` in the changelog configuration, so you rarely need to pass these on the command line. + +### Profile-based bundling [changelog-bundle-profile] + +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 +``` + +For example: + +```sh +docs-builder changelog bundle elasticsearch-release 9.2.0 +docs-builder changelog bundle elasticsearch-release ./promotion-report.html +``` + +The command automatically discovers `changelog.yml` by checking `./changelog.yml` then `./docs/changelog.yml` relative to your current directory. +If no configuration file is found, the command returns an error with advice to create one (using `docs-builder changelog init`) or to run from the directory where the file exists. + +You can set `bundle.repo` and `bundle.owner` directly under `bundle:` as defaults that apply to all profiles. +Individual profiles can override them when needed. + +Top-level `bundle` fields: + +| Field | Description | +|---|---| +| `repo` | Default GitHub repository name applied to all profiles. Falls back to product ID if not set at any level. | +| `owner` | Default GitHub repository owner applied to all profiles. | + +Profile configuration fields in `bundle.profiles`: + +| Field | Description | +|---|---| +| `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. | +| `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. | + +Example profile configuration: + +```yaml +bundle: + repo: cloud # default for all profiles + owner: elastic + profiles: + elasticsearch-release: + products: "elasticsearch {version} {lifecycle}" + output: "bundles/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_products: "cloud-serverless {version}" + # inherits repo: cloud and owner: elastic from bundle level +``` ### Filter by product [changelog-bundle-product] @@ -574,8 +644,8 @@ docs-builder changelog bundle --prs "108875,135873,136886" \ <1> 1. The comma-separated list of pull request numbers to seek. -2. The repository in the pull request URLs. This option is not required if you specify the short or full PR URLs in the `--prs` option. -3. The owner in the pull request URLs. This option is not required if you specify the short or full PR URLs in the `--prs` option. +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. 4. The product metadata for the bundle. If it is not provided, it will be derived from all the changelog product values. If you have changelog files that reference those pull requests, the command creates a file like this: @@ -601,7 +671,7 @@ entries: You can use the `--issues` option to create a bundle of changelogs that relate to those GitHub issues. Provide either a comma-separated list of issues (`--issues "https://github.com/owner/repo/issues/123,456"`) or a path to a newline-delimited file (`--issues /path/to/file.txt`). -Issues can be identified by a full URL (such as `https://github.com/owner/repo/issues/123`), a short format (such as `owner/repo#123`), or just a number (in which case you must also provide `--owner` and `--repo` options). +Issues can be identified by a full URL (such as `https://github.com/owner/repo/issues/123`), a short format (such as `owner/repo#123`), or just a number (in which case `--owner` and `--repo` are required — or set via `bundle.owner` and `bundle.repo` in the configuration). ```sh docs-builder changelog bundle --issues "12345,12346" \ @@ -872,8 +942,14 @@ You can remove the same changelogs with: docs-builder changelog remove elasticsearch-release 9.2.0 --dry-run ``` +The command automatically discovers `changelog.yml` by checking `./changelog.yml` then `./docs/changelog.yml` relative to your current directory. +If no configuration file is found, the command returns an error with advice to create one or to run from the directory where the file exists. + Only the `products` field from the profile configuration is used for removal. -The `output` and `hide_features` fields are bundle-specific and are ignored. +The `output`, `output_products`, `repo`, `owner`, and `hide_features` fields are bundle-specific and are ignored. + +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: diff --git a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs index f51f2722c..760d190f4 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs @@ -27,6 +27,17 @@ public record BundleConfiguration /// public bool Resolve { get; init; } = true; + /// + /// Default GitHub repository name applied to all profiles that do not specify their own. + /// Used for generating correct PR/issue links when the product ID differs from the repo name. + /// + public string? Repo { get; init; } + + /// + /// Default GitHub repository owner applied to all profiles that do not specify their own. + /// + public string? Owner { get; init; } + /// /// Named bundle profiles for different release scenarios. /// @@ -57,6 +68,24 @@ public record BundleProfile /// public string? Output { get; init; } + /// + /// Output products pattern. When set, overrides the products array derived from matched changelogs. + /// Supports {version} and {lifecycle} placeholders. + /// + public string? OutputProducts { get; init; } + + /// + /// GitHub repository name stored on each product in the bundle output. + /// Used for generating correct PR/issue links when the product ID differs from the repo name. + /// + public string? Repo { get; init; } + + /// + /// GitHub repository owner stored on each product in the bundle output. + /// Used for generating correct PR/issue links. Defaults to "elastic" when not specified. + /// + public string? Owner { get; init; } + /// /// Feature IDs to mark as hidden in the bundle output. /// When the bundle is rendered, entries with matching feature-id values will be commented out. diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index a5cbb0377..1d02aa9e2 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -97,9 +97,25 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle { try { - // Load changelog configuration if available + // Load changelog configuration ChangelogConfiguration? config = null; - if (_configLoader != null) + if (!string.IsNullOrWhiteSpace(input.Profile)) + { + // Profile mode requires the config file to exist — no fallback to defaults. + if (_configLoader == null) + { + collector.EmitError(string.Empty, "Changelog configuration loader is required for profile-based bundling."); + return false; + } + // When an explicit config path is provided, load it (required, no fallback). + // Otherwise, discover from CWD: ./changelog.yml then ./docs/changelog.yml. + config = string.IsNullOrWhiteSpace(input.Config) + ? await _configLoader.LoadChangelogConfigurationForProfileMode(collector, ctx) + : await _configLoader.LoadChangelogConfigurationRequired(collector, input.Config, ctx); + if (config == null) + return false; + } + else if (_configLoader != null) config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx); // Handle profile-based bundling @@ -224,8 +240,11 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle if (filterResult == null) return null; - // Resolve bundle-specific output path and hide-features from profile + // Resolve bundle-specific output path, output products, repo, owner, and hide-features from profile string? outputPath = null; + IReadOnlyList? outputProducts = null; + string? repo = null; + string? owner = null; string[]? mergedHideFeatures = null; if (config?.Bundle?.Profiles != null && config.Bundle.Profiles.TryGetValue(input.Profile!, out var profile)) @@ -237,7 +256,20 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle outputPath = _fileSystem.Path.Combine(outputDir, outputPattern); } - mergedHideFeatures = MergeHideFeatures(input.HideFeatures, profile.HideFeatures); + // Parse output_products pattern with version/lifecycle substitution + if (!string.IsNullOrWhiteSpace(profile.OutputProducts)) + { + var lifecycle = VersionLifecycleInference.InferLifecycle(filterResult.Version); + var outputProductsPattern = profile.OutputProducts + .Replace("{version}", filterResult.Version) + .Replace("{lifecycle}", lifecycle); + outputProducts = ProfileFilterResolver.ParseProfileProducts(outputProductsPattern); + } + + // Profile-level repo/owner takes precedence; fall back to bundle-level defaults + repo = profile.Repo ?? config.Bundle?.Repo; + owner = profile.Owner ?? config.Bundle?.Owner; + mergedHideFeatures = profile.HideFeatures?.Count > 0 ? [.. profile.HideFeatures] : null; } return input with @@ -245,24 +277,14 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle InputProducts = filterResult.Products, Prs = filterResult.Prs, All = false, - Output = outputPath ?? input.Output, - HideFeatures = mergedHideFeatures ?? input.HideFeatures + Output = outputPath, + OutputProducts = outputProducts, + Repo = repo, + Owner = owner, + HideFeatures = mergedHideFeatures }; } - private static string[]? MergeHideFeatures(string[]? cliHideFeatures, IReadOnlyList? profileHideFeatures) - { - if (cliHideFeatures is not { Length: > 0 } && profileHideFeatures is not { Count: > 0 }) - return null; - - var merged = new HashSet(cliHideFeatures ?? [], StringComparer.OrdinalIgnoreCase); - - if (profileHideFeatures is { Count: > 0 }) - merged.UnionWith(profileHideFeatures); - - return merged.Count > 0 ? [.. merged] : null; - } - private static BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments input, ChangelogConfiguration? config) { // Apply directory: CLI takes precedence. Only use config when --directory not specified. @@ -279,11 +301,17 @@ private static BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArg // Apply resolve: CLI takes precedence over config. Only use config when CLI did not specify. var resolve = input.Resolve ?? config.Bundle.Resolve; + // Apply repo/owner: CLI takes precedence; fall back to bundle-level config defaults. + var repo = input.Repo ?? config.Bundle.Repo; + var owner = input.Owner ?? config.Bundle.Owner; + return input with { Directory = directory, Output = output, - Resolve = resolve + Resolve = resolve, + Repo = repo, + Owner = owner }; } diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs index 31dc8500e..040492885 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs @@ -19,7 +19,7 @@ namespace Elastic.Changelog.Bundling; /// public record ChangelogRemoveArguments { - public required string Directory { get; init; } + public string? Directory { get; init; } public bool All { get; init; } public IReadOnlyList? Products { get; init; } public string[]? Prs { get; init; } @@ -62,8 +62,25 @@ public async Task RemoveChangelogs(IDiagnosticsCollector collector, Change { try { + // Load changelog configuration ChangelogConfiguration? config = null; - if (_configLoader != null) + if (!string.IsNullOrWhiteSpace(input.Profile)) + { + // Profile mode requires the config file to exist — no fallback to defaults. + if (_configLoader == null) + { + collector.EmitError(string.Empty, "Changelog configuration loader is required for profile-based removal."); + return false; + } + // When an explicit config path is provided, load it (required, no fallback). + // Otherwise, discover from CWD: ./changelog.yml then ./docs/changelog.yml. + config = string.IsNullOrWhiteSpace(input.Config) + ? await _configLoader.LoadChangelogConfigurationForProfileMode(collector, ctx) + : await _configLoader.LoadChangelogConfigurationRequired(collector, input.Config, ctx); + if (config == null) + return false; + } + else if (_configLoader != null) config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx); // Handle profile-based removal (same ordering as ChangelogBundlingService) @@ -116,13 +133,14 @@ public async Task RemoveChangelogs(IDiagnosticsCollector collector, Change } // A placeholder output path is passed to discovery so the bundle file itself is excluded. - var placeholderOutput = _fileSystem.Path.Combine(input.Directory, "changelog-bundle.yaml"); + // Directory is non-null here: ApplyConfigDefaults ensures a value and ValidateInput enforces non-empty. + var placeholderOutput = _fileSystem.Path.Combine(input.Directory!, "changelog-bundle.yaml"); var fileDiscovery = new ChangelogFileDiscovery(_fileSystem, _logger); - var yamlFiles = await fileDiscovery.DiscoverChangelogFilesAsync(input.Directory, placeholderOutput, ctx); + var yamlFiles = await fileDiscovery.DiscoverChangelogFilesAsync(input.Directory!, placeholderOutput, ctx); if (yamlFiles.Count == 0) { - collector.EmitError(input.Directory, "No changelog YAML files found in directory"); + collector.EmitError(input.Directory!, "No changelog YAML files found in directory"); return false; } @@ -196,15 +214,13 @@ public async Task RemoveChangelogs(IDiagnosticsCollector collector, Change private ChangelogRemoveArguments ApplyConfigDefaults(ChangelogRemoveArguments input, ChangelogConfiguration? config) { - if (config?.Bundle == null) - return input; + var directory = input.Directory ?? config?.Bundle?.Directory ?? _fileSystem.Directory.GetCurrentDirectory(); - var directory = input.Directory; - if ((string.IsNullOrWhiteSpace(directory) || directory == _fileSystem.Directory.GetCurrentDirectory()) - && !string.IsNullOrWhiteSpace(config.Bundle.Directory)) - directory = config.Bundle.Directory; + // Apply repo/owner: CLI takes precedence; fall back to bundle-level config defaults. + var repo = input.Repo ?? config?.Bundle?.Repo; + var owner = input.Owner ?? config?.Bundle?.Owner; - return input with { Directory = directory }; + return input with { Directory = directory, Repo = repo, Owner = owner }; } private bool ValidateInput(IDiagnosticsCollector collector, ChangelogRemoveArguments input) @@ -371,12 +387,13 @@ private async Task> FindBundleDependenciesAsync( } // 3. {directory}/bundles - var sibling = _fileSystem.Path.Combine(input.Directory, "bundles"); + // Directory is guaranteed non-null at this point (ApplyConfigDefaults + ValidateInput). + var sibling = _fileSystem.Path.Combine(input.Directory!, "bundles"); if (_fileSystem.Directory.Exists(sibling)) return sibling; // 4. {directory}/../bundles - var dirParent = _fileSystem.Path.GetDirectoryName(input.Directory); + var dirParent = _fileSystem.Path.GetDirectoryName(input.Directory!); if (!string.IsNullOrWhiteSpace(dirParent)) { var parentBundles = _fileSystem.Path.Combine(dirParent, "bundles"); diff --git a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs index 1b3daecc6..69487afcd 100644 --- a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs +++ b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs @@ -410,6 +410,9 @@ private static BundleConfiguration ParseBundleConfiguration(BundleConfigurationY { 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 }); } @@ -419,10 +422,104 @@ private static BundleConfiguration ParseBundleConfiguration(BundleConfigurationY Directory = yaml.Directory, OutputDirectory = yaml.OutputDirectory, Resolve = yaml.Resolve ?? true, + Repo = yaml.Repo, + Owner = yaml.Owner, Profiles = profiles }; } + /// + /// Loads changelog configuration from a specific path, treating a missing file as a hard error. + /// Used in profile mode when an explicit config path was provided (e.g. in tests). + /// + public async Task LoadChangelogConfigurationRequired(IDiagnosticsCollector collector, string configPath, Cancel ctx) + { + if (!fileSystem.File.Exists(configPath)) + { + collector.EmitError( + configPath, + $"Changelog configuration file not found at '{configPath}'. " + + "Either run 'docs-builder changelog init' to create one, " + + "or re-run from the folder where changelog.yml exists." + ); + return null; + } + + try + { + var yamlContent = await fileSystem.File.ReadAllTextAsync(configPath, ctx); + var yamlConfig = DeserializeConfiguration(yamlContent); + return ParseConfiguration(collector, yamlConfig, configPath); + } + catch (IOException ex) + { + collector.EmitError(configPath, $"I/O error loading changelog configuration: {ex.Message}", ex); + return null; + } + catch (UnauthorizedAccessException ex) + { + collector.EmitError(configPath, $"Access denied loading changelog configuration: {ex.Message}", ex); + return null; + } + catch (YamlDotNet.Core.YamlException ex) + { + collector.EmitError(configPath, $"YAML parsing error in changelog configuration: {ex.Message}", ex); + return null; + } + } + + /// + /// Discovers and loads the changelog configuration for profile mode. + /// Unlike , this method treats a missing config file as a + /// hard error. It searches for changelog.yml then docs/changelog.yml relative to the + /// current working directory, so the command works when run from any folder that contains the file. + /// + public async Task LoadChangelogConfigurationForProfileMode(IDiagnosticsCollector collector, Cancel ctx) + { + var cwd = fileSystem.Directory.GetCurrentDirectory(); + var candidates = new[] + { + fileSystem.Path.Combine(cwd, "changelog.yml"), + fileSystem.Path.Combine(cwd, "docs", "changelog.yml") + }; + + var foundPath = candidates.FirstOrDefault(fileSystem.File.Exists); + + if (foundPath == null) + { + collector.EmitError( + string.Empty, + "changelog.yml not found. Profile-based commands require a changelog configuration file. " + + "Either run 'docs-builder changelog init' to create one, " + + "or re-run this command from the folder where changelog.yml exists " + + "(e.g. the project root if the file is at docs/changelog.yml)." + ); + return null; + } + + try + { + var yamlContent = await fileSystem.File.ReadAllTextAsync(foundPath, ctx); + var yamlConfig = DeserializeConfiguration(yamlContent); + return ParseConfiguration(collector, yamlConfig, foundPath); + } + catch (IOException ex) + { + collector.EmitError(foundPath, $"I/O error loading changelog configuration: {ex.Message}", ex); + return null; + } + catch (UnauthorizedAccessException ex) + { + collector.EmitError(foundPath, $"Access denied loading changelog configuration: {ex.Message}", ex); + return null; + } + catch (YamlDotNet.Core.YamlException ex) + { + collector.EmitError(foundPath, $"YAML parsing error in changelog configuration: {ex.Message}", ex); + return null; + } + } + private RulesConfiguration? ParseRulesConfiguration( IDiagnosticsCollector collector, RulesConfigurationYaml? rulesYaml, diff --git a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs index f6c6555da..8688e402d 100644 --- a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs +++ b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs @@ -212,6 +212,16 @@ internal record BundleConfigurationYaml /// public bool? Resolve { get; set; } + /// + /// Default GitHub repository name applied to all profiles that do not specify their own. + /// + public string? Repo { get; set; } + + /// + /// Default GitHub repository owner applied to all profiles that do not specify their own. + /// + public string? Owner { get; set; } + /// /// Named bundle profiles. /// @@ -235,6 +245,23 @@ internal record BundleProfileYaml /// public string? Output { get; set; } + /// + /// Output products pattern. Overrides the products array derived from matched changelogs. + /// Supports {version} and {lifecycle} placeholders. + /// + public string? OutputProducts { get; set; } + + /// + /// GitHub repository name for generating PR/issue links in bundle output. + /// + public string? Repo { get; set; } + + /// + /// GitHub repository owner for generating PR/issue links in bundle output. + /// Defaults to "elastic" when not specified. + /// + public string? Owner { get; set; } + /// /// Feature IDs to mark as hidden in the bundle output (string or list). /// diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 4b4da38b4..4f1a63fbb 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -403,9 +403,9 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// 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`. /// 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. - /// GitHub repository owner (required when PRs or issues are specified as numbers) + /// 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`. - /// GitHub repository name. Used for PR or issue filtering when PRs or issues are specified as numbers, and also sets the repo field in the bundle output for generating correct PR/issue links. If not specified, the product ID is used as the repo name in links. + /// 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: 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). /// @@ -477,9 +477,53 @@ public async Task Bundle( } } - // In raw mode (no profile), validate filter options - if (!isProfileMode) + // Validate filter/output options against profile mode + if (isProfileMode) + { + var forbidden = new List(); + if (all) + forbidden.Add("--all"); + if (inputProducts is { Count: > 0 }) + forbidden.Add("--input-products"); + if (outputProducts is { Count: > 0 }) + forbidden.Add("--output-products"); + if (allPrs.Count > 0) + forbidden.Add("--prs"); + if (allIssues.Count > 0) + forbidden.Add("--issues"); + if (!string.IsNullOrWhiteSpace(output)) + forbidden.Add("--output"); + if (!string.IsNullOrWhiteSpace(repo)) + forbidden.Add("--repo"); + if (!string.IsNullOrWhiteSpace(owner)) + forbidden.Add("--owner"); + if (resolve.HasValue) + forbidden.Add("--resolve"); + if (noResolve) + forbidden.Add("--no-resolve"); + if (hideFeatures is { Length: > 0 }) + forbidden.Add("--hide-features"); + if (!string.IsNullOrWhiteSpace(config)) + forbidden.Add("--config"); + if (!string.IsNullOrWhiteSpace(directory)) + forbidden.Add("--directory"); + + if (forbidden.Count > 0) + { + collector.EmitError( + string.Empty, + $"When using a profile, the following options are not allowed: {string.Join(", ", forbidden)}. " + + "All paths and filters are derived from the changelog configuration file." + ); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + } + else { + // Raw mode: require exactly one filter option var specifiedFilters = new List(); if (all) specifiedFilters.Add("--all"); @@ -508,17 +552,6 @@ public async Task Bundle( return 1; } } - else - { - if (all || (inputProducts != null && inputProducts.Count > 0) || allPrs.Count > 0 || allIssues.Count > 0) - { - collector.EmitError(string.Empty, "When using a profile, do not specify --all, --input-products, --prs, or --issues. The profile configuration determines the filter."); - _ = collector.StartAsync(ctx); - await collector.WaitForDrain(); - await collector.StopAsync(ctx); - return 1; - } - } // Validate that if inputProducts is provided, all three parts (product, target, lifecycle) are present for each entry // They can be wildcards (*) but must be present @@ -665,10 +698,10 @@ async static (s, collector, state, ctx) => await s.BundleChangelogs(collector, s /// 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. - /// GitHub repository owner (required when PRs or issues are specified as numbers) + /// 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. - /// GitHub repository name (required when PRs or issues are specified as numbers) + /// Optional: GitHub repository name. Required when PRs or issues are specified as numbers. Falls back to bundle.repo in changelog.yml when not specified. /// [Command("remove")] public async Task Remove( @@ -722,10 +755,35 @@ public async Task Remove( if (isProfileMode) { - // Profile mode: --all, --products, --prs, and --issues must not be used - if (all || (products != null && products.Count > 0) || allPrs.Count > 0 || allIssues.Count > 0) + // Profile mode: reject all options except --dry-run and --force + var forbidden = new List(); + if (all) + forbidden.Add("--all"); + if (products is { Count: > 0 }) + forbidden.Add("--products"); + if (allPrs.Count > 0) + forbidden.Add("--prs"); + if (allIssues.Count > 0) + forbidden.Add("--issues"); + if (!string.IsNullOrWhiteSpace(repo)) + forbidden.Add("--repo"); + if (!string.IsNullOrWhiteSpace(owner)) + forbidden.Add("--owner"); + if (!string.IsNullOrWhiteSpace(config)) + forbidden.Add("--config"); + if (!string.IsNullOrWhiteSpace(directory)) + forbidden.Add("--directory"); + if (!string.IsNullOrWhiteSpace(bundlesDir)) + forbidden.Add("--bundles-dir"); + + if (forbidden.Count > 0) { - collector.EmitError(string.Empty, "When using a profile, do not specify --all, --products, --prs, or --issues. The profile configuration determines the filter."); + collector.EmitError( + string.Empty, + $"When using a profile, the following options are not allowed: {string.Join(", ", forbidden)}. " + + "All paths and filters are derived from the changelog configuration file. " + + "Allowed options with profiles: --dry-run, --force." + ); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -791,9 +849,13 @@ 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()); + var input = new ChangelogRemoveArguments { - Directory = NormalizePath(directory ?? Directory.GetCurrentDirectory()), + Directory = resolvedDirectory, All = all, Products = products, Prs = allPrs.Count > 0 ? allPrs.ToArray() : null, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index 6fa23d772..e0f1e9498 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -1972,6 +1972,118 @@ public async Task BundleChangelogs_WithoutRepoOption_OmitsRepoFieldInOutput() bundleContent.Should().NotContain("repo:"); } + [Fact] + public async Task BundleChangelogs_WithBundleLevelRepoConfig_UsesConfigRepoWhenOptionNotSpecified() + { + // Arrange - bundle.repo in config is used when --repo is not provided on the CLI + + // language=yaml + var configContent = + """ + bundle: + repo: cloud + owner: elastic + """; + + 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 feature + type: feature + products: + - product: cloud-serverless + target: 2025-06-01 + lifecycle: ga + prs: + - https://github.com/elastic/cloud/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-serverless-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, 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, + All = true, + Config = configPath, + Output = outputPath + // No --repo or --owner: should be picked up from bundle config + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed, but got 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("repo: cloud", "bundle.repo config should be applied when --repo is not specified"); + } + + [Fact] + public async Task BundleChangelogs_WithRepoOptionAndBundleLevelConfig_CliOptionTakesPrecedence() + { + // Arrange - explicit --repo overrides bundle.repo in config + + // language=yaml + var configContent = + """ + bundle: + repo: wrong-repo + """; + + 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 feature + type: feature + products: + - product: cloud-serverless + target: 2025-06-01 + lifecycle: ga + prs: + - https://github.com/elastic/cloud/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-serverless-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, 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, + All = true, + Config = configPath, + Output = outputPath, + Repo = "cloud" // explicit CLI --repo should win over bundle.repo: wrong-repo + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed, but got 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("repo: cloud", "explicit --repo should override bundle.repo config"); + bundleContent.Should().NotContain("repo: wrong-repo"); + } + [Fact] public async Task BundleChangelogs_WithOutputProductsAndRepo_IncludesRepoInAllProducts() { @@ -2264,9 +2376,11 @@ public async Task BundleChangelogs_WithProfileHideFeatures_IncludesHideFeaturesI } [Fact] - public async Task BundleChangelogs_WithProfileAndCliHideFeatures_MergesBothSources() + public async Task BundleChangelogs_WithProfile_OnlyProfileHideFeaturesAreUsed() { - // Arrange - Test that CLI --hide-features and profile hide_features are merged + // Arrange - In profile mode, only hide_features from the profile config are written to the bundle. + // Any HideFeatures passed directly to the service are ignored (the CLI now rejects --hide-features + // in profile mode, so this combination is not reachable from the command layer). // language=yaml var configContent = @@ -2309,8 +2423,7 @@ public async Task BundleChangelogs_WithProfileAndCliHideFeatures_MergesBothSourc Profile = "es-release", ProfileArgument = "9.2.0", Config = configPath, - OutputDirectory = outputDir, - HideFeatures = ["feature:from-cli"] + OutputDirectory = outputDir }; // Act @@ -2325,16 +2438,15 @@ public async Task BundleChangelogs_WithProfileAndCliHideFeatures_MergesBothSourc outputFiles.Should().NotBeEmpty("Expected an output file to be created"); var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); - // Verify that hide-features from BOTH sources are present + // Verify that only the profile hide-features are present bundleContent.Should().Contain("hide-features:"); bundleContent.Should().Contain("- feature:from-profile"); - bundleContent.Should().Contain("- feature:from-cli"); } [Fact] - public async Task BundleChangelogs_WithProfileAndCliHideFeatures_DeduplicatesFeatureIds() + public async Task BundleChangelogs_WithProfileMultipleHideFeatures_AllProfileFeaturesArePresent() { - // Arrange - Test that duplicate feature IDs from CLI and profile are deduplicated + // Arrange - All hide_features from the profile are written to the bundle // language=yaml var configContent = @@ -2345,8 +2457,8 @@ public async Task BundleChangelogs_WithProfileAndCliHideFeatures_DeduplicatesFea products: "elasticsearch {version} {lifecycle}" output: "elasticsearch-{version}.yaml" hide_features: - - feature:shared - - feature:profile-only + - feature:profile-one + - feature:profile-two """; var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), "config3", "changelog.yml"); @@ -2378,8 +2490,7 @@ public async Task BundleChangelogs_WithProfileAndCliHideFeatures_DeduplicatesFea Profile = "es-release", ProfileArgument = "9.2.0", Config = configPath, - OutputDirectory = outputDir, - HideFeatures = ["feature:shared", "feature:cli-only"] // "feature:shared" overlaps with profile + OutputDirectory = outputDir }; // Act @@ -2393,14 +2504,8 @@ public async Task BundleChangelogs_WithProfileAndCliHideFeatures_DeduplicatesFea outputFiles.Should().NotBeEmpty("Expected an output file to be created"); var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); - // Verify all unique features are present - bundleContent.Should().Contain("- feature:shared"); - bundleContent.Should().Contain("- feature:profile-only"); - bundleContent.Should().Contain("- feature:cli-only"); - - // Count occurrences of "feature:shared" - should appear exactly once (deduplicated) - var sharedCount = bundleContent.Split("feature:shared").Length - 1; - sharedCount.Should().Be(1, "Duplicate feature IDs should be deduplicated"); + bundleContent.Should().Contain("- feature:profile-one"); + bundleContent.Should().Contain("- feature:profile-two"); } [Fact] @@ -2737,6 +2842,477 @@ public async Task AmendBundle_WithResolve_ProducesNormalizedChecksum() amendContent.Should().Contain("type: feature"); } + [Fact] + public async Task BundleChangelogs_WithProfile_OutputProducts_OverridesProductsArray() + { + // Arrange - output_products overrides the products array written to the bundle. + // The profile uses a wildcard lifecycle to match any changelog for the given version, + // while output_products pins the lifecycle advertised in the bundle output to "ga". + + // language=yaml + var configContent = + """ + bundle: + profiles: + es-release: + products: "elasticsearch {version} *" + output: "elasticsearch-{version}.yaml" + output_products: "elasticsearch {version} ga" + """; + + 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: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: preview + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Profile = "es-release", + ProfileArgument = "9.2.0", + Config = configPath, + OutputDirectory = outputDir + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + outputFiles.Should().NotBeEmpty("Expected an output file to be created"); + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); + + // output_products overrides: the products array in the bundle output should have lifecycle: ga + // even though the matched changelog has lifecycle: preview + bundleContent.Should().Contain("lifecycle: ga", "output_products should write lifecycle: ga to the bundle products array"); + } + + [Fact] + public async Task BundleChangelogs_WithProfile_RepoAndOwner_WritesValuesToProductEntries() + { + // Arrange - repo and owner in the profile are written to each product entry in the bundle. + // Note: date-based versions like "2025-06-01" contain dashes that InferLifecycle treats as + // a semver prerelease, so we use a wildcard lifecycle in the products pattern to match any + // lifecycle value present in the changelog files. + + // language=yaml + var configContent = + """ + bundle: + profiles: + serverless-release: + products: "cloud-serverless {version} *" + output: "serverless-{version}.yaml" + repo: cloud + owner: elastic + """; + + 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 feature + type: feature + products: + - product: cloud-serverless + target: 2025-06-01 + lifecycle: ga + prs: + - https://github.com/elastic/cloud/pull/200 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-serverless-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Profile = "serverless-release", + ProfileArgument = "2025-06-01", + Config = configPath, + OutputDirectory = outputDir + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + outputFiles.Should().NotBeEmpty("Expected an output file to be created"); + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); + + // Only repo is persisted in the bundle products array; owner is used for PR URL normalization at render time + bundleContent.Should().Contain("repo: cloud", "Profile repo should be written to bundle product entries"); + bundleContent.Should().NotContain("owner:", "owner is not stored in the bundle products array"); + } + + [Fact] + public async Task BundleChangelogs_WithProfile_BundleLevelRepo_AppliesWhenProfileOmitsRepo() + { + // Arrange - repo is set at bundle level, not in the profile; profile should inherit it + + // language=yaml + var configContent = + """ + bundle: + repo: elasticsearch + owner: elastic + profiles: + es-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch-{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: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Profile = "es-release", + ProfileArgument = "9.2.0", + Config = configPath, + OutputDirectory = outputDir + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + outputFiles.Should().NotBeEmpty(); + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); + + bundleContent.Should().Contain("repo: elasticsearch", "bundle-level repo should be applied when profile omits repo"); + } + + [Fact] + public async Task BundleChangelogs_WithProfile_ProfileRepoOverridesBundleRepo() + { + // Arrange - both bundle-level and profile-level repo are set; profile-level wins + + // language=yaml + var configContent = + """ + bundle: + repo: wrong-repo + profiles: + es-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch-{version}.yaml" + repo: elasticsearch + """; + + 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: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Profile = "es-release", + ProfileArgument = "9.2.0", + Config = configPath, + OutputDirectory = outputDir + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + outputFiles.Should().NotBeEmpty(); + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); + + bundleContent.Should().Contain("repo: elasticsearch", "profile-level repo should override bundle-level repo"); + bundleContent.Should().NotContain("repo: wrong-repo", "bundle-level repo should be overridden by profile-level repo"); + } + + [Fact] + public async Task BundleChangelogs_WithProfile_NoRepoOwner_PreservesExistingFallbackBehavior() + { + // Arrange - when profile has no repo/owner, the bundle products have no repo field + // (existing fallback: product ID is used at render time if no repo is present) + + // language=yaml + var configContent = + """ + bundle: + profiles: + es-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch-{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: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Profile = "es-release", + ProfileArgument = "9.2.0", + Config = configPath, + OutputDirectory = outputDir + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert — succeeds without error; no repo field written to products + result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + outputFiles.Should().NotBeEmpty("Expected an output file to be created"); + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); + + bundleContent.Should().NotContain("repo:", "No repo field should be present when profile omits repo"); + bundleContent.Should().NotContain("owner:", "No owner field should be present when profile omits owner"); + } + + [Fact] + public async Task BundleChangelogs_WithProfileMode_MissingConfig_ReturnsErrorWithAdvice() + { + // Arrange - no config file exists at ./changelog.yml or ./docs/changelog.yml. + // Use a fresh MockFileSystem with a known CWD so discovery returns no results. + var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem( + null, + currentDirectory: "/empty-project" + ); + cwdFs.Directory.CreateDirectory("/empty-project"); + var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, cwdFs); + + var input = new BundleChangelogsArguments + { + Profile = "es-release", + ProfileArgument = "9.2.0" + // Config intentionally omitted — triggers CWD discovery + }; + + // Act + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse("Should fail when no config file is found"); + Collector.Diagnostics.Should().ContainSingle(d => + d.Severity == Severity.Error && + (d.Message.Contains("changelog.yml") || d.Message.Contains("changelog init")), + "Error message should mention changelog.yml or advise running changelog init" + ); + } + + [Fact] + public async Task BundleChangelogs_WithProfileMode_ConfigAtCurrentDir_LoadsSuccessfully() + { + // Arrange - changelog.yml is at ./changelog.yml (in the current working directory) + var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem( + null, + currentDirectory: "/test-root" + ); + cwdFs.Directory.CreateDirectory("/test-root"); + cwdFs.Directory.CreateDirectory("/test-root/changelogs"); + cwdFs.Directory.CreateDirectory("/test-root/output"); + + // language=yaml + var configContent = + """ + bundle: + directory: /test-root/changelogs + profiles: + es-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch-{version}.yaml" + """; + await cwdFs.File.WriteAllTextAsync("/test-root/changelog.yml", configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelogContent = + """ + title: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + await cwdFs.File.WriteAllTextAsync("/test-root/changelogs/1755268130-feature.yaml", changelogContent, TestContext.Current.CancellationToken); + + var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, cwdFs); + + var input = new BundleChangelogsArguments + { + Profile = "es-release", + ProfileArgument = "9.2.0", + OutputDirectory = "/test-root/output" + // Config intentionally omitted — should discover /test-root/changelog.yml + }; + + // Act + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed. Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + cwdFs.Directory.GetFiles("/test-root/output", "*.yaml").Should().NotBeEmpty("Expected output file to be created"); + } + + [Fact] + public async Task BundleChangelogs_WithProfileMode_ConfigAtDocsSubdir_LoadsSuccessfully() + { + // Arrange - changelog.yml is at ./docs/changelog.yml (the second discovery candidate) + var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem( + null, + currentDirectory: "/test-root" + ); + cwdFs.Directory.CreateDirectory("/test-root"); + cwdFs.Directory.CreateDirectory("/test-root/docs"); + cwdFs.Directory.CreateDirectory("/test-root/changelogs"); + cwdFs.Directory.CreateDirectory("/test-root/output"); + + // language=yaml + var configContent = + """ + bundle: + directory: /test-root/changelogs + profiles: + es-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch-{version}.yaml" + """; + // Config is in docs/ subdir, not in CWD directly + await cwdFs.File.WriteAllTextAsync("/test-root/docs/changelog.yml", configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelogContent = + """ + title: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + await cwdFs.File.WriteAllTextAsync("/test-root/changelogs/1755268130-feature.yaml", changelogContent, TestContext.Current.CancellationToken); + + var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, cwdFs); + + var input = new BundleChangelogsArguments + { + Profile = "es-release", + ProfileArgument = "9.2.0", + OutputDirectory = "/test-root/output" + // Config intentionally omitted — should discover /test-root/docs/changelog.yml + }; + + // Act + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed. Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + cwdFs.Directory.GetFiles("/test-root/output", "*.yaml").Should().NotBeEmpty("Expected output file to be created"); + } + 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 dc7a7bb2d..00e2d07cb 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs @@ -606,6 +606,37 @@ public async Task Remove_WithProfile_MissingProfileArg_ReturnsError() d.Message.Contains("requires a version number")); } + [Fact] + public async Task Remove_WithProfileMode_MissingConfig_ReturnsErrorWithAdvice() + { + // Arrange - no config file exists at ./changelog.yml or ./docs/changelog.yml. + // Use a fresh MockFileSystem with a known CWD so discovery returns no results. + var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem( + null, + currentDirectory: "/empty-project" + ); + cwdFs.Directory.CreateDirectory("/empty-project"); + var service = new ChangelogRemoveService(LoggerFactory, ConfigurationContext, cwdFs); + + var input = new ChangelogRemoveArguments + { + Profile = "es-release", + ProfileArgument = "9.2.0" + // Config intentionally omitted — triggers CWD discovery + }; + + // Act + var result = await service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse("Should fail when no config file is found"); + Collector.Diagnostics.Should().ContainSingle(d => + d.Severity == Severity.Error && + (d.Message.Contains("changelog.yml") || d.Message.Contains("changelog init")), + "Error message should mention changelog.yml or advise running changelog init" + ); + } + [Fact] public async Task Remove_WithProfile_NoProductsAndVersionArg_ReturnsSpecificError() {