diff --git a/config/changelog.example.yml b/config/changelog.example.yml index 1a2e480849..3ccc5d14e2 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -246,20 +246,25 @@ bundle: # repo: elasticsearch # Optional: default GitHub owner applied to all profiles that do not specify their own. # owner: elastic - # Optional: branch whose CDN changelog pool (changelog/{org}/{repo}/{branch}/...) is sourced from when + # Optional: branch whose CDN changelog entries (changelog/{org}/{repo}/{branch}/...) are sourced from when # bundling entries from the CDN. Defaults to "main" when unset. Can be overridden per profile. # branch: main # Optional: control auto-population of release-date for all profiles by default. # When true (default), auto-populate release dates. Profiles can override this setting. # release_dates: true + # Optional: when true, git-ref bundling synthesizes changelog entries from GitHub PR metadata + # when no matching changelog YAML is found on the CDN (or in the local folder when local + # sourcing is forced). Default false: unmatched PRs are warned and omitted like --prs. + # Override per profile or with CLI --infer. + # infer_missing_changelogs: false # 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', 'output_products', 'repo', 'owner', and 'hide_features' fields are - # bundle-specific and are ignored for removal. + # The 'output', 'output_products', 'repo', 'owner', 'hide_features', and + # 'infer_missing_changelogs' fields are bundle-specific and are ignored for removal. profiles: # Example: Elasticsearch release profile (filter by changelog fields) # elasticsearch-release: diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 9298a52cc8..9852a85266 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -3240,7 +3240,7 @@ "name": "start-git-ref", "type": "string", "required": false, - "summary": "Start ref (exclusive) of a git commit range to bundle, for example the previously published endpoint ref. Must be provided together with --end-git-ref; the start ref is never inferred. The PR list is derived from the range itself (GitHub compare API \u002B GraphQL associatedPullRequests), each PR\u0027s entry is sourced pool-first with PR-metadata fallback, and requires GITHUB_TOKEN. Supported in profile-based commands (for example, \u0027bundle serverless-release 2026-08-13 --start-git-ref abc123 --end-git-ref def456\u0027); mutually exclusive with all other filter options." + "summary": "Start ref (exclusive) of a git commit range to bundle, for example the previously published endpoint ref. Must be provided together with --end-git-ref; the start ref is never inferred. The PR list is derived from the range itself (GitHub compare API \u002B GraphQL associatedPullRequests). Each PR\u0027s changelog YAML is sourced from the CDN (or the local folder when local sourcing is forced); unmatched PRs are warned and omitted unless --infer or bundle.infer_missing_changelogs is set. Requires GITHUB_TOKEN. Supported in profile-based commands (for example, \u0027bundle serverless-release 2026-08-13 --start-git-ref abc123 --end-git-ref def456\u0027); mutually exclusive with all other filter options." }, { "role": "flag", @@ -3254,7 +3254,15 @@ "name": "dry-run", "type": "boolean", "required": false, - "summary": "Resolve the commit range and print the run report (resolved PR list with per-PR entry source: pool, inferred, or missing) as Markdown without writing a bundle. Only valid together with --start-git-ref/--end-git-ref. Supported in profile-based commands.", + "summary": "Resolve the commit range and print the run report (resolved PR list with per-PR entry source: pool, no changelog, inferred, or missing) as Markdown without writing a bundle. Only valid together with --start-git-ref/--end-git-ref. Supported in profile-based commands.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "infer", + "type": "boolean", + "required": false, + "summary": "When bundling a git commit range, synthesize in-memory changelog entries from GitHub PR metadata for PRs with no matching changelog YAML on the CDN (or in the local folder when using --force-local). Default: unmatched PRs are warned and omitted like --prs. Equivalent to bundle.infer_missing_changelogs: true without editing config. Allowed in profile-based commands. Only valid together with --start-git-ref/--end-git-ref.", "defaultValue": "false" }, { @@ -3333,7 +3341,7 @@ "name": "add", "type": "array", "required": false, - "summary": "Optional: Changelog YAML paths to add. Repeat --add or pass a comma-separated list in one value (for example, --add \u0022file1.yaml,file2.yaml\u0022). Supports tilde (~) expansion and relative paths.", + "summary": "Optional: Changelog YAML paths to add. Repeat --add or pass a comma-separated list in one value (for example, --add \u0022file1.yaml,file2.yaml\u0022). Supports tilde (~) expansion and relative paths. When entries are sourced from the CDN (the default when bundle.repo or the parent bundle\u0027s repo resolves), paths are matched by file name and do not need to exist locally; with local sourcing (--force-local or bundle.use_local_changelogs) the paths must exist on disk.", "repeatable": true, "elementType": "string" }, @@ -3342,7 +3350,7 @@ "name": "remove", "type": "array", "required": false, - "summary": "Optional: Changelog YAML paths to exclude from the effective bundle. Repeat --remove or pass a comma-separated list in one value. Supports tilde (~) expansion and relative paths.", + "summary": "Optional: Changelog YAML paths to exclude from the effective bundle. Repeat --remove or pass a comma-separated list in one value. Supports tilde (~) expansion and relative paths. When entries are sourced from the CDN, paths are matched by file name and do not need to exist locally; with local sourcing the paths must exist on disk unless --force is used to exclude by file name.", "repeatable": true, "elementType": "string" }, @@ -3351,7 +3359,15 @@ "name": "force", "type": "boolean", "required": false, - "summary": "Optional: When removing, match by file name even if the bundle checksum differs from the file on disk.", + "summary": "Optional: When removing, match by file name even if the bundle checksum differs from the sourced changelog, or when no YAML can be sourced (inferred git-ref entries).", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "force-local", + "type": "boolean", + "required": false, + "summary": "Optional: Force local entry sourcing for this run (equivalent to bundle.use_local_changelogs: true without editing config).", "defaultValue": "false" }, { diff --git a/docs/cli/changelog/cmd-bundle-amend.md b/docs/cli/changelog/cmd-bundle-amend.md index a0a1b67fb6..cb586c7e90 100644 --- a/docs/cli/changelog/cmd-bundle-amend.md +++ b/docs/cli/changelog/cmd-bundle-amend.md @@ -60,16 +60,31 @@ The result is rendered as a single release. Amend bundles created by older docs-builder versions may omit `products`; they are still accepted when loading and merge into their parent as before. `hide-features` is always inherited from the parent bundle. If an amend bundle is found without a matching parent bundle, it remains standalone. `rules.bundle` filtering does not apply to `changelog bundle-amend`. The command is a direct-injection escape hatch: the files you specify with `--add` are always included regardless of any product, type, or area filter configuration. + +`--add` and `--remove` follow the same entry-sourcing gate as [](/cli/changelog/bundle.md): CDN by default when `bundle.repo` or the parent bundle's `repo` resolves; local disk when `--force-local` or `bundle.use_local_changelogs` is set, or when no authoring repo can be resolved. In CDN mode, paths are matched by file name (including CDN paths such as `/changelog/elastic/kibana/main/247279.yaml`) and do not need to exist locally. Use `--force-local` to read local changelogs from disk. + +The parent bundle argument is always a local file. The command writes `{parent}.amend-N.yaml` next to it and does not fetch the parent from the CDN. ::: ## Examples -### Add a single changelog to a bundle +### Add a changelog from the CDN + +The first argument is the local parent bundle. `--add` can be a CDN path (matched by file name) when entry sourcing uses the CDN: ```sh docs-builder changelog bundle-amend \ ./docs/changelog/bundles/9.3.0.yaml \ - --add ./docs/changelog/138723.yaml + --add /changelog/elastic/kibana/main/138723.yaml +``` + +### Add a single local changelog to a bundle + +```sh +docs-builder changelog bundle-amend \ + ./docs/changelog/bundles/9.3.0.yaml \ + --add ./docs/changelog/138723.yaml \ + --force-local ``` ### Remove a changelog from a bundle @@ -77,12 +92,25 @@ docs-builder changelog bundle-amend \ ```sh docs-builder changelog bundle-amend \ ./docs/changelog/bundles/9.3.0.yaml \ - --remove ./docs/changelog/138723.yaml + --remove /changelog/elastic/kibana/main/138723.yaml ``` -The CLI computes the file checksum automatically and matches it against the effective bundle (parent plus any existing amend files). +The CLI computes the checksum of the sourced YAML and matches it against the effective bundle (parent plus any existing amend files). If the bundle contains the file with a different checksum, the command fails unless you pass `--force` to remove by file name only. +### Remove an inferred git-ref entry [inferred-git-ref-entry] + +Git-ref bundles created with `--infer` (or `infer_missing_changelogs: true`) can include entries that were synthesized from GitHub PR metadata. Those entries live only in the bundle. Their `file.name` is `{pull-request-number}.yaml`, and there is no changelog YAML on disk or on the CDN whose checksum you can match. + +Pass `--force` so matching is by filename only. The path does not need to exist: + +```sh +docs-builder changelog bundle-amend ./docs/releases/cloud-hosted-2026-08-13.yaml \ + --remove 300.yaml --force +``` + +Refer to [](/cli/changelog/bundle.md#inferred-entries) for the full workflow, including how to replace inferred copy with a real changelog. + ### Add multiple changelogs to a bundle Comma-separated list: @@ -90,7 +118,8 @@ Comma-separated list: ```sh docs-builder changelog bundle-amend \ ./docs/changelog/bundles/9.3.0.yaml \ - --add "./docs/changelog/138723.yaml,./docs/changelog/1770424335.yaml" + --add "./docs/changelog/138723.yaml,./docs/changelog/1770424335.yaml" \ + --force-local ``` Or repeat `--add`: @@ -99,7 +128,8 @@ Or repeat `--add`: docs-builder changelog bundle-amend \ ./docs/changelog/bundles/9.3.0.yaml \ --add ./docs/changelog/138723.yaml \ - --add ./docs/changelog/1770424335.yaml + --add ./docs/changelog/1770424335.yaml \ + --force-local ``` ### Remove multiple changelogs from a bundle @@ -107,7 +137,8 @@ docs-builder changelog bundle-amend \ ```sh docs-builder changelog bundle-amend \ ./docs/changelog/bundles/9.3.0.yaml \ - --remove "./docs/changelog/old-a.yaml,./docs/changelog/old-b.yaml" + --remove "./docs/changelog/old-a.yaml,./docs/changelog/old-b.yaml" \ + --force-local ``` ### Replace an entry in one amend file @@ -116,7 +147,8 @@ docs-builder changelog bundle-amend \ docs-builder changelog bundle-amend \ ./docs/changelog/bundles/9.3.0.yaml \ --remove ./docs/changelog/old-entry.yaml \ - --add ./docs/changelog/new-entry.yaml + --add ./docs/changelog/new-entry.yaml \ + --force-local ``` ### Preview without writing an amend file @@ -124,6 +156,6 @@ docs-builder changelog bundle-amend \ ```sh docs-builder changelog bundle-amend \ ./docs/changelog/bundles/9.3.0.yaml \ - --remove ./docs/changelog/138723.yaml \ + --remove /changelog/elastic/kibana/main/138723.yaml \ --dry-run ``` diff --git a/docs/cli/changelog/cmd-bundle.md b/docs/cli/changelog/cmd-bundle.md index 726c63025a..c9b9993fcf 100644 --- a/docs/cli/changelog/cmd-bundle.md +++ b/docs/cli/changelog/cmd-bundle.md @@ -54,6 +54,8 @@ Exactly one of the following filter flags is required: `--force-local` is not a filter. It forces local entry sourcing for the run (equivalent to `bundle.use_local_changelogs: true` without editing config) and is allowed in both option-based and profile-based modes. +`--infer` is not a filter. It is allowed in both option-based and profile-based modes, but only together with `--start-git-ref`/`--end-git-ref`. Equivalent to `bundle.infer_missing_changelogs: true`. + ```sh # Bundle all changelogs in docs/changelog/ docs-builder changelog bundle --all --directory docs/changelog @@ -90,10 +92,10 @@ Both refs are always required together — the start ref is never inferred from 1. Enumerates the commits in `start..end` via the GitHub compare API (paginated). 2. Resolves each commit to its merged pull request via GraphQL `associatedPullRequests`. Works for squash and merge commits on protected integration branches; commits with no associated PR are reported, never silently dropped. When a commit is associated with multiple merged PRs, the command warns and picks deterministically (merge-commit match first, then lowest PR number). -3. Sources each PR's changelog entry with a fixed precedence: - - **A checked-in entry from the entry pool wins.** Pool entries are matched by file-name-derived PR numbers (file names survive scrubbing, so this works for private repos whose `prs` references were removed from public copies) or by the entry's `prs` references. - - **Otherwise the entry is synthesized from PR metadata** — the same extraction path `changelog add` uses: release-note text from the PR body becomes the description, and labels map to type/areas/products/feature-id via the `pivot.*` configuration. `rules.create` label rules decide inclusion. - - **PRs whose metadata cannot be fetched are reported as missing** with a warning. +3. Sources each PR's changelog YAML from the **CDN** (default) or the **local folder** (`--force-local`, `--directory`, or `bundle.use_local_changelogs`): + - Entries are matched by file-name-derived PR numbers (file names survive scrubbing, so this works for private repos whose `prs` references were removed from public CDN copies) or by the entry's `prs` references. + - PRs with no matching changelog YAML are **warned and omitted**, the same as `--prs`. A missing changelog is valid when the change is not notable. The run succeeds as long as at least one matching entry remains. + - Pass `--infer` (or set `bundle.infer_missing_changelogs: true` / `bundle.profiles..infer_missing_changelogs: true`) to **synthesize** an in-memory entry from GitHub PR metadata instead — the same extraction path `changelog add` uses: release-note text from the PR body becomes the description, and labels map to type/areas/products/feature-id via the `pivot.*` configuration. `rules.create` label rules decide inclusion. PRs whose metadata cannot be fetched are reported as missing with a warning. 4. Records the end ref in the bundle output as the `git_ref` metadata field. Commit-range mode works in both profile-based and option-based commands and is mutually exclusive with every other filter. In profile-based commands the profile contributes output metadata only (`output_products`, `repo`, `owner`, `rules`, and so on) — it must not set a `products` pattern or `source: github_release`. When the profile has no explicit `output` pattern, the bundle name follows the `{product}-{version}.yaml` convention. @@ -106,13 +108,35 @@ Commit-range mode requires a `GITHUB_TOKEN` environment variable: the GraphQL AP ### Dry run -Pass `--dry-run` to resolve the range and print the run report — the resolved PR list with each PR's entry source (`pool`, `inferred (PR body)`, `inferred (title)`, `excluded (rules)`, or `missing`) plus any commits without an associated PR — as Markdown, without writing a bundle. The report is suitable for a release PR body or a CI job summary. +Pass `--dry-run` to resolve the range and print the run report — the resolved PR list with each PR's entry source (`cdn`, `no changelog`, `inferred (PR body)`, `inferred (title)`, `excluded (rules)`, or `missing`) plus any commits without an associated PR — as Markdown, without writing a bundle. The report is suitable for a release PR body or a CI job summary. `cdn` means a matching changelog YAML was found (from the public CDN, or from the local folder when you force local sourcing). By default, unmatched PRs appear as `no changelog`; `inferred` rows appear only when `--infer` (or the equivalent YAML) is set. ```sh docs-builder changelog bundle serverless-release 2026-08-13 \ --start-git-ref abc123 --end-git-ref def456 --dry-run ``` +To include synthesized copy for PRs that have no matching changelog YAML on the CDN: + +```sh +docs-builder changelog bundle serverless-release 2026-08-13 \ + --start-git-ref abc123 --end-git-ref def456 --infer +``` + +### Inferred entries and bundle-amend [inferred-entries] + +An inferred entry is embedded in the bundle like any other changelog: full `title`, `type`, `products`, and the rest of the fields. Its `file.name` is `{pull-request-number}.yaml` (for example `300.yaml` for PR 300). That name is what the `--dry-run` report lists in the Entry column. `docs-builder` does not write a changelog YAML file for that PR to disk, S3, or the CDN. + +`changelog bundle-amend --remove` excludes by file name. Pass `--force` when there is no source YAML (inferred entries have none on disk or on the CDN) so matching is by filename only: + +```sh +docs-builder changelog bundle-amend ./docs/releases/cloud-hosted-2026-08-13.yaml \ + --remove 300.yaml --force +``` + +To replace the inferred copy, exclude it that way, then `--add` a real changelog YAML (from the CDN, or from disk with `--force-local`). The added file's name becomes the new `file.name`. + +If you can, author a real changelog (`changelog add`, submit, and upload) so the next git-ref bundle picks it up from the CDN. You can then amend it with a normal `--remove` / `--add` path. Refer to [](/cli/changelog/bundle-amend.md#inferred-git-ref-entry). + ## Bundles are self-contained Every bundle embeds the full content of each changelog entry (`title`, `type`, `products`, and so on), plus a `file` block recording the source file name and checksum for provenance. Rendering — via the `{changelog}` directive, `changelog render`, or the CDN pipeline — never reads the original changelog files, so you can clean them up with `docs-builder changelog remove` immediately after bundling. @@ -408,7 +432,7 @@ In profile mode, pass the same path list as a positional argument: docs-builder changelog bundle serverless-release 2026-07-07 ./docs/temp/changelog_files.txt ``` -`--files` / path-list selection follows the standard entry-sourcing rules. When entries are sourced from the CDN (the default when `bundle.repo` resolves), the listed paths are matched to CDN pool entries by file name and do not need to exist locally — useful for private repositories whose entries exist only in S3 and whose public copies have PR/issue references scrubbed, so PR-based filters cannot match. With local sourcing (`--force-local`, `--directory`, or `bundle.use_local_changelogs`), the listed files are read from disk and must exist. In either mode, a listed entry that cannot be found fails the run, and `rules.bundle` still applies after selection. +`--files` / path-list selection follows the standard entry-sourcing rules. When entries are sourced from the CDN (the default when `bundle.repo` resolves), the listed paths are matched to CDN entries by file name and do not need to exist locally — useful for private repositories whose entries exist only in S3 and whose public copies have PR/issue references scrubbed, so PR-based filters cannot match. With local sourcing (`--force-local`, `--directory`, or `bundle.use_local_changelogs`), the listed files are read from disk and must exist. In either mode, a listed entry that cannot be found fails the run, and `rules.bundle` still applies after selection. ### Force local entry sourcing [changelog-bundle-force-local] @@ -420,7 +444,7 @@ docs-builder changelog bundle serverless-release 2026-07-07 ./docs/temp/prs.txt ``` `--force-local` is allowed in both option-based and profile-based commands. -Use it with path-list / `--files` filters when the listed files should be read from disk instead of matched against the CDN pool. +Use it with path-list / `--files` filters when the listed files should be read from disk instead of matched against the CDN. ### Hide features [changelog-bundle-hide-features] diff --git a/docs/cli/changelog/cmd-remove.md b/docs/cli/changelog/cmd-remove.md index 441553d72d..4703ed9231 100644 --- a/docs/cli/changelog/cmd-remove.md +++ b/docs/cli/changelog/cmd-remove.md @@ -58,6 +58,6 @@ The `--products` filter supports wildcards: When `changelog.yml` defines `bundle.profiles`, use those same profiles with `changelog remove` to remove exactly the changelogs that would be included in a matching bundle. -Profile-based commands discover the changelog configuration automatically: they look for `changelog.yml` in the current directory, then `docs/changelog.yml`. +Profile-based commands discover the changelog configuration automatically: they look for `changelog.yml` in the current directory, then `docs/changelog.yml`. Profile fields that only affect bundle output (`output`, `output_products`, `hide_features`, `infer_missing_changelogs`) are ignored. Refer to [](/data/release-notes/bundle.md#changelog-remove) for examples. diff --git a/docs/data/release-notes/bundle.md b/docs/data/release-notes/bundle.md index beae7c8d16..3932e5a25c 100644 --- a/docs/data/release-notes/bundle.md +++ b/docs/data/release-notes/bundle.md @@ -128,8 +128,8 @@ bundle: output_products: "cloud-serverless {version}" <2> ``` -1. The authoring repository whose commit range is resolved and whose entry pool is consulted. -2. Also applied to entries synthesized from PR metadata when the PR's labels map to no product. When the profile has no `output` pattern, the bundle is named `{product}-{version}.yaml` by convention. +1. The authoring repository whose commit range is resolved and whose uploaded changelog entries (CDN, or local folder when local sourcing is forced) are consulted. +2. Also applied to entries synthesized from PR metadata when inferral is on (`--infer` or `infer_missing_changelogs: true`) and the PR's labels map to no product. When the profile has no `output` pattern, the bundle is named `{product}-{version}.yaml` by convention. ```sh docs-builder changelog bundle serverless-release 2026-08-13 \ @@ -137,7 +137,7 @@ docs-builder changelog bundle serverless-release 2026-08-13 \ --end-git-ref ``` -For each PR in the range, a checked-in changelog entry (already uploaded to the entry pool) wins; otherwise an entry is synthesized from the PR's title, labels, and release-note text using the same extraction path as `changelog add`. The bundle records the end ref in a `git_ref` metadata field. Refer to [Commit-range mode](/cli/changelog/bundle.md#git-ref-mode) for the full behavior, including the `--dry-run` run report. +For each PR in the range, a matching changelog YAML on the CDN (or in the local folder when you force local sourcing) is included. PRs with no matching changelog are warned and omitted (the change may not be notable), matching `--prs`. Pass `--infer` or set `infer_missing_changelogs: true` on the bundle or profile to synthesize an entry from the PR's title, labels, and release-note text using the same extraction path as `changelog add`. Inferred entries are stored only in the bundle, with a synthetic `file.name` of `{pull-request-number}.yaml` — they are not uploaded to the CDN. [](/cli/changelog/bundle.md#inferred-entries) covers how to update or remove one. The bundle records the end ref in a `git_ref` metadata field. Refer to [Commit-range mode](/cli/changelog/bundle.md#git-ref-mode) for the full behavior, including the `--dry-run` run report. ### Bundle by folder or changelog product @@ -286,14 +286,16 @@ To apply additional filtering by the changelog type, areas, or products, add [bu ## Amend bundles [changelog-bundle-amend] When you need to add changelogs to an existing bundle, you can use the `docs-builder changelog bundle-amend` command, which creates _amend bundles_. -For example: +The parent bundle path must be a local file (the amend sidecar is written next to it). `--add` and `--remove` accept the same CDN paths as `changelog bundle --files` when the authoring repo resolves. For example: ```sh docs-builder changelog bundle-amend \ ./docs/releases/9.3.0.yaml \ - --add "./docs/changelog/138723.yaml,./docs/changelog/1770424335.yaml" + --add /changelog/elastic/kibana/main/138723.yaml ``` +To read local changelog files from disk instead of the CDN, pass `--force-local`. + Amend bundles follow a specific naming convention: `{parent-bundle-name}.amend-{N}.yaml` where `{N}` is a sequence number. To remove entries from an existing bundle without editing the parent file, use `--remove` on the same command: @@ -301,11 +303,13 @@ To remove entries from an existing bundle without editing the parent file, use ` ```sh docs-builder changelog bundle-amend \ ./docs/releases/9.3.0.yaml \ - --remove "./docs/changelog/138723.yaml" + --remove /changelog/elastic/kibana/main/138723.yaml ``` This creates an amend file with `exclude-entries` that is merged when the bundle is rendered. +`--remove` matches the sourced changelog (CDN or local) by name and checksum. Inferred git-ref entries have no such file; pass `--force` with the basename `{pull-request-number}.yaml`. Refer to [](/cli/changelog/bundle-amend.md#inferred-git-ref-entry). + When bundles are turned into docs (either via the `changelog render` command or the `{changelog}` directive), amend files are **automatically merged** with their parent bundles. The changelogs from all matching amend files are combined with the parent bundle's changelogs and the result is rendered as a single release. diff --git a/docs/data/release-notes/configure-ref.md b/docs/data/release-notes/configure-ref.md index 5e8add87d2..0ac7242a93 100644 --- a/docs/data/release-notes/configure-ref.md +++ b/docs/data/release-notes/configure-ref.md @@ -46,8 +46,9 @@ These settings are relevant to one or all of the `changelog bundle`, `changelog | Setting | Description | | ------------------------- | ----------- | -| `bundle.branch` | Branch whose CDN changelog pool (`changelog/{org}/{repo}/{branch}/...`) entries are sourced from when bundling (default: `main`). Refer to [Entry sourcing](#bundle-entry-sourcing). | +| `bundle.branch` | Branch whose CDN changelog entries (`changelog/{org}/{repo}/{branch}/...`) are sourced from when bundling (default: `main`). Refer to [Entry sourcing](#bundle-entry-sourcing). | | `bundle.directory` | Input directory containing changelog YAML files (default: `docs/changelog`). | +| `bundle.infer_missing_changelogs` | When `true`, git-ref bundling synthesizes in-memory changelog entries from GitHub PR metadata when no matching changelog YAML is found on the CDN (or in the local folder when local sourcing is forced). Default `false`: unmatched PRs are warned and omitted like `--prs`. Override with `--infer` or a profile-level setting. | | `bundle.link_allow_repos` | List of `owner/repo` pairs whose PR/issue links are preserved. When set (including empty `[]`), links to unlisted repos become `# PRIVATE:` sentinels. | | `bundle.output_directory` | Output directory for bundled files (default: `docs/releases`). | | `bundle.owner` | Default GitHub repository owner (for example, `elastic`). Also the org segment of uploaded changelog-entry keys (`changelog/{org}/{repo}/{branch}/...`) and CDN entry sourcing. | @@ -73,9 +74,9 @@ The authoring repo is resolved with the same precedence as `changelog upload`: ` Sourcing is decided per run: - **Local folder.** Used when `bundle.use_local_changelogs: true`, when `--force-local` is passed, when `--directory` is passed, or when the authoring repo cannot be resolved. The folder must contain the changelog files. -- **CDN (default when a repo resolves).** Used when the authoring repo resolves, local sourcing is not forced, and a CDN base URL is configured (`DOCS_BUILDER_CHANGELOG_CDN`, defaulting to the public distribution). The command fetches `changelog/{org}/{repo}/{branch}/registry.json` and the entries it lists, then applies the bundle's own product/PR/issue/file filters to the downloaded set. Path-list / `--files` filters match pool entries by file name, so the listed paths do not need to exist locally. +- **CDN (default when a repo resolves).** Used when the authoring repo resolves, local sourcing is not forced, and a CDN base URL is configured (`DOCS_BUILDER_CHANGELOG_CDN`, defaulting to the public distribution). The command fetches `changelog/{org}/{repo}/{branch}/registry.json` and the entries it lists, then applies the bundle's own product/PR/issue/file filters to the downloaded set. Path-list / `--files` filters match CDN entries by file name, so the listed paths do not need to exist locally. `changelog bundle-amend --add` / `--remove` use the same gate: CDN paths are matched by file name; `--force-local` reads local files instead. -Use `--force-local` for uncommon ad hoc runs that need the local folder without editing `changelog.yml` — including path-list / `--files` runs that should read freshly authored files from disk instead of the CDN pool. +Use `--force-local` for uncommon ad hoc runs that need the local folder without editing `changelog.yml` — including path-list / `--files` runs and `changelog bundle-amend --add` / `--remove` that should read freshly authored files from disk instead of the CDN. Because entries are org/repo/branch-scoped, one repository can produce a bundle for a shared product (for example, `cloud-serverless`) while sourcing its own entries from `changelog/{org}/{repo}/{branch}/`, without that product appearing in the repository's `docset.yml`. The `{changelog}` directive's `:cdn:` mode still consumes product-scoped *bundles*, so a repository that also renders its own release notes declares each product under `release_notes` as before. @@ -123,6 +124,10 @@ These settings are located in the `bundle.profiles.` section of the config : Feature IDs to mark as hidden in the bundle. : When the bundle is rendered, entries with matching `feature-id` values are commented out. +`infer_missing_changelogs` +: Overrides [bundle.infer_missing_changelogs](#bundle-basic). +: When `true`, git-ref bundling synthesizes entries from GitHub PR metadata for PRs with no matching changelog YAML on the CDN (or in the local folder when local sourcing is forced). Default is omit-with-warning. CLI `--infer` turns this on for a single run. + `output` : The output filename pattern for the bundle file. : Supports `{version}` and `{lifecycle}` placeholders. diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index 2038eed926..d01af14f1c 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -421,7 +421,7 @@ This prevents silent data loss where changelog entries would be quietly omitted To fix this, re-create the bundle with `docs-builder changelog bundle` so every entry is embedded. -`bundle-amend --remove` only applies when the source changelog file is still available (for example, to drop an entry from the effective bundle before you delete the file with `changelog remove`). +`bundle-amend --remove` excludes an entry from the effective bundle. When the changelog exists on the CDN (or still on disk), the command matches it by file name and checksum. Use `--force` to match by file name only, including inferred git-ref entries that have no source YAML. ## Example diff --git a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs index f75564bda3..c8eed41479 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs @@ -64,6 +64,14 @@ public record BundleConfiguration /// public bool? ReleaseDates { get; init; } + /// + /// When true, git-ref bundling synthesizes in-memory changelog entries from GitHub PR metadata + /// when no matching changelog YAML is found on the CDN (or in the local folder when local + /// sourcing is forced). Defaults to false: unmatched PRs are warned and omitted. + /// Overridden by CLI --infer or a profile-level setting. + /// + public bool? InferMissingChangelogs { get; init; } + /// /// Named bundle profiles for different release scenarios. /// @@ -135,6 +143,14 @@ public record BundleProfile /// public bool? ReleaseDates { get; init; } + /// + /// When true, git-ref bundling synthesizes in-memory changelog entries from GitHub PR metadata + /// when no matching changelog YAML is found on the CDN (or in the local folder when local + /// sourcing is forced). Overrides . + /// Defaults to false when omitted at both levels. Overridden by CLI --infer. + /// + public bool? InferMissingChangelogs { get; init; } + /// /// Profile source type. When set to "github_release", the profile fetches /// PR references directly from a GitHub release and uses them as the bundle filter. diff --git a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs index 733fc51586..221a27c192 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs @@ -530,6 +530,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) Branch = kvp.Value.Branch, HideFeatures = kvp.Value.HideFeatures?.Values, ReleaseDates = kvp.Value.ReleaseDates, + InferMissingChangelogs = kvp.Value.InferMissingChangelogs, Source = kvp.Value.Source }); } @@ -544,6 +545,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) Owner = yaml.Owner, Branch = yaml.Branch, ReleaseDates = yaml.ReleaseDates, + InferMissingChangelogs = yaml.InferMissingChangelogs, LinkAllowRepos = linkAllowRepos, Profiles = profiles }; diff --git a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationYaml.cs b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationYaml.cs index 09acb46e9d..2cc8243dc5 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationYaml.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationYaml.cs @@ -321,6 +321,12 @@ internal sealed record BundleConfigurationYaml /// public bool? ReleaseDates { get; set; } + /// + /// When true, git-ref bundling synthesizes changelog entries from GitHub PR metadata when no + /// matching changelog YAML is found on the CDN. Defaults to false. + /// + public bool? InferMissingChangelogs { get; set; } + /// /// When set, only PR/issue links targeting these owner/repo values are kept; others become # PRIVATE: sentinels. /// @@ -386,6 +392,12 @@ internal sealed record BundleProfileYaml /// public bool? ReleaseDates { get; set; } + /// + /// When true, git-ref bundling synthesizes changelog entries from GitHub PR metadata when no + /// matching changelog YAML is found on the CDN. Overrides bundle-level infer_missing_changelogs. + /// + public bool? InferMissingChangelogs { get; set; } + /// /// Profile source type. When set to "github_release", the profile fetches /// PR references directly from a GitHub release and uses them as the bundle filter. diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs index c5eb895480..076e181d70 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs @@ -13,12 +13,22 @@ namespace Elastic.Documentation.Configuration.ReleaseNotes; /// public readonly record struct CdnChangelogEntry(string FileName, string Content); +/// +/// Result of fetching a subset of changelog entries by file name from a CDN pool. +/// +/// Successfully downloaded entries. +/// Requested names that the pool registry does not list. +public readonly record struct CdnNamedFetchResult( + IReadOnlyList Entries, + IReadOnlyList MissingFromRegistry); + /// /// Fetches the individual (scrubbed) changelog entries for a single authoring org/repo/branch pool from -/// the public CDN, for the changelog bundle command when sourcing entries from S3 rather than a -/// local folder. It reads {base}/changelog/{org}/{repo}/{branch}/registry.json to enumerate entries -/// and downloads each {base}/changelog/{org}/{repo}/{branch}/{file} as raw YAML; the bundle command -/// then applies its usual filter (products / prs / issues) to the downloaded set. +/// the public CDN, for the changelog bundle and changelog bundle-amend commands when sourcing +/// entries from S3 rather than a local folder. It reads {base}/changelog/{org}/{repo}/{branch}/registry.json +/// to enumerate entries and downloads {base}/changelog/{org}/{repo}/{branch}/{file} as raw YAML. +/// changelog bundle downloads the full pool then filters; changelog bundle-amend downloads +/// only the requested file names. /// /// /// @@ -106,45 +116,11 @@ public async Task> FetchAsync( Action emitWarning, Cancel ctx) { - var poolLabel = $"{org}/{repo}/{branch}"; - - // Defense-in-depth: org/repo come from config and branch from config or CLI on the consumer side. - // Reject anything the producer would have refused to upload before building the URI, so it cannot - // normalize a "../" into a different changelog pool than intended. - if (!ChangelogKeys.IsValidOrg(org) || !ChangelogKeys.IsValidRepo(repo) || !ChangelogKeys.IsValidBranch(branch)) - { - emitError( - $"Invalid changelog pool '{poolLabel}': the org, repo, and each '/'-delimited branch segment must be non-empty ASCII letters, digits, '.', '_' or '-' (org allows only letters, digits and '-') and must not be '.' or '..'."); + var loaded = await TryLoadRegistryAsync(baseUri, org, repo, branch, emitError, ctx).ConfigureAwait(false); + if (loaded is null) return []; - } - - var poolSegments = ChangelogKeys.PoolSegments(org, repo, branch); - var registryUri = CombineSegments(baseUri, [.. poolSegments, ChangelogKeys.RegistryFileName]); - - ChangelogRegistry? registry; - try - { - registry = await FetchRegistryAsync(registryUri, ctx).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - emitError($"Could not fetch changelog entry registry for '{poolLabel}' from {registryUri}: {ex.Message}"); - return []; - } - - if (registry is null) - { - emitError($"Changelog entry registry for '{poolLabel}' at {registryUri} was empty or unparseable."); - return []; - } - - if (registry.SchemaVersion > SupportedSchemaVersion) - { - emitError( - $"Changelog entry registry for '{poolLabel}' uses schema version {registry.SchemaVersion}, but this build only understands version {SupportedSchemaVersion}. Update docs-builder."); - return []; - } + var (poolLabel, poolSegments, registry) = loaded.Value; var entries = new List(registry.Bundles.Count); foreach (var entry in registry.Bundles) { @@ -178,6 +154,122 @@ public async Task> FetchAsync( return entries; } + /// + /// Downloads only the requested changelog entries from the authoring pool. Reads the pool registry + /// once, then GETs each requested name that the registry lists. Names missing from the registry are + /// returned in rather than treated as fatal + /// so callers such as bundle-amend --force can fall back to name-only matching. + /// Returns null after emitting an error when the registry cannot be read or a registry-listed + /// entry cannot be fetched. + /// + public async Task FetchNamedAsync( + Uri baseUri, + string org, + string repo, + string branch, + IReadOnlyList fileNames, + Action emitError, + Cancel ctx) + { + var loaded = await TryLoadRegistryAsync(baseUri, org, repo, branch, emitError, ctx).ConfigureAwait(false); + if (loaded is null) + return null; + + var (poolLabel, poolSegments, registry) = loaded.Value; + var listed = new HashSet(StringComparer.Ordinal); + foreach (var entry in registry.Bundles) + { + if (!string.IsNullOrWhiteSpace(entry.File)) + _ = listed.Add(entry.File); + } + + var entries = new List(); + var missing = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var fileName in fileNames) + { + ctx.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(fileName) || !seen.Add(fileName)) + continue; + + if (!listed.Contains(fileName)) + { + missing.Add(fileName); + continue; + } + + if (!ChangelogKeys.IsSafeFileName(fileName)) + { + emitError($"Changelog entry '{fileName}' for '{poolLabel}' has an invalid file name."); + return null; + } + + var entryUri = CombineSegments(baseUri, [.. poolSegments, fileName]); + var (fetched, content, lastError) = await TryFetchEntryAsync(entryUri, fileName, poolLabel, ctx).ConfigureAwait(false); + if (!fetched) + { + emitError( + $"Changelog entry '{fileName}' for '{poolLabel}' is listed in the registry but could not be fetched from {entryUri} after {_maxAttempts} attempt(s): {lastError}. " + + "The scrubbed copy may not have propagated to the CDN yet; retry shortly, and if it persists check the changelog scrubber pipeline."); + return null; + } + + entries.Add(new CdnChangelogEntry(fileName, content)); + } + + _logger.LogInformation( + "Fetched {Count} requested changelog entry(ies) for {Pool} from {BaseUri} ({Missing} missing from registry)", + entries.Count, poolLabel, baseUri, missing.Count); + return new CdnNamedFetchResult(entries, missing); + } + + private async Task<(string PoolLabel, IReadOnlyList PoolSegments, ChangelogRegistry Registry)?> TryLoadRegistryAsync( + Uri baseUri, + string org, + string repo, + string branch, + Action emitError, + Cancel ctx) + { + var poolLabel = $"{org}/{repo}/{branch}"; + + if (!ChangelogKeys.IsValidOrg(org) || !ChangelogKeys.IsValidRepo(repo) || !ChangelogKeys.IsValidBranch(branch)) + { + emitError( + $"Invalid changelog pool '{poolLabel}': the org, repo, and each '/'-delimited branch segment must be non-empty ASCII letters, digits, '.', '_' or '-' (org allows only letters, digits and '-') and must not be '.' or '..'."); + return null; + } + + var poolSegments = ChangelogKeys.PoolSegments(org, repo, branch); + var registryUri = CombineSegments(baseUri, [.. poolSegments, ChangelogKeys.RegistryFileName]); + + ChangelogRegistry? registry; + try + { + registry = await FetchRegistryAsync(registryUri, ctx).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + emitError($"Could not fetch changelog entry registry for '{poolLabel}' from {registryUri}: {ex.Message}"); + return null; + } + + if (registry is null) + { + emitError($"Changelog entry registry for '{poolLabel}' at {registryUri} was empty or unparseable."); + return null; + } + + if (registry.SchemaVersion > SupportedSchemaVersion) + { + emitError( + $"Changelog entry registry for '{poolLabel}' uses schema version {registry.SchemaVersion}, but this build only understands version {SupportedSchemaVersion}. Update docs-builder."); + return null; + } + + return (poolLabel, poolSegments, registry); + } + /// /// Fetches a single entry, retrying transient failures (most importantly a not-yet-propagated 404) /// up to times with exponential backoff. Retry requests are cache-busted diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs index 6cdd7f54ff..73b9dfc950 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs @@ -41,10 +41,15 @@ public record AmendBundleArguments public IReadOnlyList RemoveFiles { get; init; } = []; /// - /// Remove by file name when the bundle checksum does not match the file on disk. + /// Remove by file name when the bundle checksum does not match the sourced changelog. /// public bool Force { get; init; } + /// + /// Force local entry sourcing for this run (CLI --force-local). + /// + public bool ForceLocal { get; init; } + /// /// Preview changes without writing an amend file. /// @@ -57,7 +62,8 @@ public record AmendBundleArguments public partial class ChangelogBundleAmendService( ILoggerFactory logFactory, IChangelogFileSystem fileSystem, - IConfigurationContext? configurationContext = null) : IService + IConfigurationContext? configurationContext = null, + CdnChangelogEntryFetcher? entryFetcher = null) : IService { /// /// UTF-8 encoding without BOM for writing YAML files. @@ -66,6 +72,7 @@ public partial class ChangelogBundleAmendService( private readonly ILogger _logger = logFactory.CreateLogger(); private readonly IChangelogFileSystem _fileSystem = fileSystem; + private readonly CdnChangelogEntryFetcher _entryFetcher = entryFetcher ?? new CdnChangelogEntryFetcher(logFactory); private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem) : null; @@ -96,19 +103,63 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle return false; } - var addFilePaths = ValidateInputFiles(collector, input.AddFiles, "--add"); - if (addFilePaths == null) + var (parentOk, parentBundle) = await TryDeserializeParentBundleAsync( + input.BundlePath, + collector, + ctx); + if (!parentOk || parentBundle == null) return false; - var removeFilePaths = ValidateInputFiles(collector, input.RemoveFiles, "--remove"); - if (removeFilePaths == null) + ChangelogConfiguration? changelogConfig = null; + if (_configLoader != null) + { + changelogConfig = await _configLoader.LoadChangelogConfiguration(collector, null, ctx); + if (changelogConfig is null) + return false; + } + + var useLocalChangelogs = (changelogConfig?.Bundle?.UseLocalChangelogs ?? false) || input.ForceLocal; + var authoringRepo = ChangelogRepoOwnerResolver.NormalizeRepo( + changelogConfig?.Bundle?.Repo ?? (parentBundle.Products.Count > 0 ? parentBundle.Products[0].Repo : null)); + var useCdn = ChangelogEntrySourcing.ShouldSourceFromCdn(authoringRepo, useLocalChangelogs: useLocalChangelogs); + + IReadOnlyDictionary? cdnContents = null; + if (useCdn) + { + var fetched = await FetchCdnContentsAsync( + collector, + parentBundle, + changelogConfig, + authoringRepo!, + [.. input.AddFiles, .. input.RemoveFiles], + ctx); + if (fetched is null) + return false; + cdnContents = fetched; + } + + var addSources = await SourceInputFilesAsync( + collector, + input.AddFiles, + "--add", + useCdn, + cdnContents, + requireContent: true, + force: false, + ctx); + if (addSources is null) return false; - var (parentOk, parentBundle) = await TryDeserializeParentBundleAsync( - input.BundlePath, + var removeSources = await SourceInputFilesAsync( collector, + input.RemoveFiles, + "--remove", + useCdn, + cdnContents, + requireContent: false, + force: input.Force, ctx); - if (!parentOk || parentBundle == null) + if (removeSources is null) return false; var (amendsOk, existingAmendBundles) = await LoadExistingAmendBundlesAsync( @@ -122,15 +173,14 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle var appliedExclusionKeys = BundleAmendMerger.CollectAppliedExclusionKeys(existingAmendBundles); var excludeEntries = new List(); - foreach (var removeFilePath in removeFilePaths!) + foreach (var removeSource in removeSources) { - var exclusion = await BuildExclusionEntryAsync( + var exclusion = BuildExclusionEntry( collector, - removeFilePath, + removeSource, effectiveEntries, appliedExclusionKeys, - input.Force, - ctx); + input.Force); if (exclusion == null) return false; if (exclusion is RemoveExclusionResult.Skip) @@ -141,18 +191,11 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle _ = appliedExclusionKeys.Add(BundleAmendMerger.BuildExclusionKey(entry)); } - ChangelogConfiguration? changelogConfig = null; - IReadOnlyList? linkAllowRepos = null; - var linkAllowlistActive = false; - if (_configLoader != null && addFilePaths!.Count > 0) - { - changelogConfig = await _configLoader.LoadChangelogConfiguration(collector, null, ctx); - linkAllowRepos = changelogConfig?.Bundle?.LinkAllowRepos; - linkAllowlistActive = linkAllowRepos != null; - } + var linkAllowRepos = changelogConfig?.Bundle?.LinkAllowRepos; + var linkAllowlistActive = linkAllowRepos != null; var entries = new List(); - if (addFilePaths!.Count > 0) + if (addSources.Count > 0) { if (linkAllowlistActive) { @@ -179,9 +222,9 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle } } - foreach (var filePath in addFilePaths) + foreach (var addSource in addSources) { - var entry = await LoadChangelogFileAsync(collector, filePath, ctx); + var entry = LoadChangelogContent(collector, addSource); if (entry == null) return false; entries.Add(entry); @@ -284,32 +327,121 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle } } - private List? ValidateInputFiles( + private async Task?> FetchCdnContentsAsync( + IDiagnosticsCollector collector, + Bundle parentBundle, + ChangelogConfiguration? changelogConfig, + string authoringRepo, + IReadOnlyList paths, + Cancel ctx) + { + var parentOwner = parentBundle.Products.Count > 0 ? parentBundle.Products[0].Owner : null; + var owner = ChangelogRepoOwnerResolver.ResolveOwner( + changelogConfig?.Bundle?.Owner, + changelogConfig?.Bundle?.Repo, + parentOwner) ?? ChangelogEntrySourcing.DefaultOwner; + var configuredBranch = changelogConfig?.Bundle?.Branch; + var branch = string.IsNullOrWhiteSpace(configuredBranch) + ? ChangelogEntrySourcing.DefaultBranch + : configuredBranch; + + var baseUri = ChangelogCdn.ResolveBaseUri(); + if (baseUri is null) + { + collector.EmitError(string.Empty, + $"No valid changelog CDN base URL is configured. Set the {ChangelogCdn.BaseUrlEnvironmentVariable} environment variable to an absolute http(s) URL."); + return null; + } + + var names = paths + .Select(p => _fileSystem.Path.GetFileName(p)) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + var fatal = false; + var result = await _entryFetcher.FetchNamedAsync( + baseUri, + owner, + authoringRepo, + branch, + names, + msg => { fatal = true; collector.EmitError(string.Empty, msg); }, + ctx).ConfigureAwait(false); + + if (fatal || result is null) + return null; + + var contents = new Dictionary(StringComparer.Ordinal); + foreach (var entry in result.Value.Entries) + contents[entry.FileName] = entry.Content; + + return contents; + } + + private async Task?> SourceInputFilesAsync( IDiagnosticsCollector collector, IReadOnlyList files, - string optionName) + string optionName, + bool useCdn, + IReadOnlyDictionary? cdnContents, + bool requireContent, + bool force, + Cancel ctx) { if (files.Count == 0) return []; - var validatedPaths = new List(); + var sourced = new List(); foreach (var file in files) { - if (!_fileSystem.File.Exists(file)) + var fileName = _fileSystem.Path.GetFileName(file); + if (useCdn) { - var currentDir = _fileSystem.Directory.GetCurrentDirectory(); + if (cdnContents is not null && cdnContents.TryGetValue(fileName, out var cdnYaml)) + { + sourced.Add(new SourcedChangelog(fileName, cdnYaml, file)); + continue; + } + + if (!requireContent && force) + { + sourced.Add(new SourcedChangelog(fileName, Content: null, file)); + continue; + } + collector.EmitError( file, - $"File does not exist. Current directory: {currentDir}. " + - $"Tip: Repeat {optionName} for each file, or use comma-separated values (e.g., {optionName} \"file1.yaml,file2.yaml\"). " + - "Paths support tilde (~) expansion and can be relative or absolute." - ); + requireContent + ? $"Changelog '{fileName}' was not found in the CDN pool. Ensure the entry was uploaded (changelog upload), or pass --force-local to read a local file." + : $"Changelog '{fileName}' was not found in the CDN pool. Ensure the entry was uploaded (changelog upload), pass --force-local to read a local file, or pass --force to exclude by file name."); return null; } - validatedPaths.Add(file); + + if (_fileSystem.File.Exists(file)) + { + var content = await _fileSystem.File.ReadAllTextAsync(file, ctx).ConfigureAwait(false); + sourced.Add(new SourcedChangelog(fileName, content, file)); + continue; + } + + if (!requireContent && force) + { + sourced.Add(new SourcedChangelog(fileName, Content: null, file)); + continue; + } + + var currentDir = _fileSystem.Directory.GetCurrentDirectory(); + collector.EmitError( + file, + $"File does not exist. Current directory: {currentDir}. " + + $"Tip: Repeat {optionName} for each file, or use comma-separated values (e.g., {optionName} \"file1.yaml,file2.yaml\"). " + + "Paths support tilde (~) expansion and can be relative or absolute. " + + "When sourcing from the CDN, paths are matched by file name and do not need to exist locally."); + return null; } - return validatedPaths; + return sourced; } private async Task<(bool Ok, List Bundles)> LoadExistingAmendBundlesAsync( @@ -338,17 +470,17 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle return (true, amendBundles); } - private async Task BuildExclusionEntryAsync( + private RemoveExclusionResult? BuildExclusionEntry( IDiagnosticsCollector collector, - string removeFilePath, + SourcedChangelog source, IReadOnlyList effectiveEntries, HashSet appliedExclusionKeys, - bool force, - Cancel ctx) + bool force) { - var fileName = _fileSystem.Path.GetFileName(removeFilePath); - var content = await _fileSystem.File.ReadAllTextAsync(removeFilePath, ctx); - var fileChecksum = ChangelogBundlingService.ComputeSha1(content); + var fileName = source.FileName; + var fileChecksum = source.Content is null + ? string.Empty + : ChangelogBundlingService.ComputeSha1(source.Content); var strictExclusion = new BundledEntry { @@ -360,19 +492,17 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle }; var exclusionKey = BundleAmendMerger.BuildExclusionKey(strictExclusion); - if (appliedExclusionKeys.Contains(exclusionKey)) + if (!string.IsNullOrEmpty(fileChecksum) && appliedExclusionKeys.Contains(exclusionKey)) { collector.EmitWarning( - removeFilePath, + source.DisplayPath, $"Changelog '{fileName}' is already excluded by a prior amend file; skipping."); return RemoveExclusionResult.Skip.Instance; } - var strictMatches = effectiveEntries - .Where(entry => BundleAmendMerger.EntryMatchesExclusion(entry, strictExclusion)) - .ToList(); - - var matchedEntry = strictMatches.Count > 0 ? strictMatches[0] : null; + var matchedEntry = source.Content is null + ? null + : effectiveEntries.FirstOrDefault(entry => BundleAmendMerger.EntryMatchesExclusion(entry, strictExclusion)); if (matchedEntry == null) { @@ -392,7 +522,7 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle if (nameMatches.Count == 0) { collector.EmitError( - removeFilePath, + source.DisplayPath, $"Changelog '{fileName}' was not found in the effective bundle (parent plus existing amend files)."); return null; } @@ -400,8 +530,8 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle if (!force) { collector.EmitError( - removeFilePath, - $"Bundle contains '{fileName}' but with a different checksum than the file on disk. " + + source.DisplayPath, + $"Bundle contains '{fileName}' but with a different checksum than the sourced changelog. " + "Re-create the bundle or use --force to remove by file name only."); return null; } @@ -410,6 +540,22 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle } var exclusionChecksum = matchedEntry.File?.Checksum ?? fileChecksum; + var appliedKey = BundleAmendMerger.BuildExclusionKey(new BundledEntry + { + File = new BundledFile + { + Name = fileName, + Checksum = exclusionChecksum + } + }); + if (appliedExclusionKeys.Contains(appliedKey)) + { + collector.EmitWarning( + source.DisplayPath, + $"Changelog '{fileName}' is already excluded by a prior amend file; skipping."); + return RemoveExclusionResult.Skip.Instance; + } + return new RemoveExclusionResult.Add(new BundledEntry { File = new BundledFile @@ -477,26 +623,27 @@ private string GenerateAmendFilePath(string bundlePath, int amendNumber) return _fileSystem.Path.Join(directory, $"{baseName}.amend-{amendNumber}{extension}"); } - private async Task LoadChangelogFileAsync( + private BundledEntry? LoadChangelogContent( IDiagnosticsCollector collector, - string filePath, - Cancel ctx) + SourcedChangelog source) { try { - var fileName = _fileSystem.Path.GetFileName(filePath); - var content = await _fileSystem.File.ReadAllTextAsync(filePath, ctx); - - var checksum = ChangelogBundlingService.ComputeSha1(content); + if (source.Content is null) + { + collector.EmitError(source.DisplayPath, "Cannot add a changelog without sourced YAML content."); + return null; + } - var normalizedYaml = ReleaseNotesSerialization.NormalizeYaml(content); + var checksum = ChangelogBundlingService.ComputeSha1(source.Content); + var normalizedYaml = ReleaseNotesSerialization.NormalizeYaml(source.Content); var entry = ReleaseNotesSerialization.DeserializeEntry(normalizedYaml); return new BundledEntry { File = new BundledFile { - Name = fileName, + Name = source.FileName, Checksum = checksum }, Type = entry.Type, @@ -515,11 +662,13 @@ private string GenerateAmendFilePath(string bundlePath, int amendNumber) } catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException)) { - collector.EmitError(filePath, $"Failed to load changelog file: {ex.Message}", ex); + collector.EmitError(source.DisplayPath, $"Failed to load changelog file: {ex.Message}", ex); return null; } } + private readonly record struct SourcedChangelog(string FileName, string? Content, string DisplayPath); + /// /// Discovers amend files for a bundle /// diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index c9b9e65bda..8efc5c1b11 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -52,6 +52,13 @@ public record BundleChangelogsArguments /// public bool ForceLocal { get; init; } + /// + /// When true, git-ref bundling synthesizes in-memory changelog entries from GitHub PR metadata + /// when no matching changelog YAML is found on the CDN (CLI --infer). Combined with + /// bundle.infer_missing_changelogs / profile override; default is false. + /// + public bool InferMissingChangelogs { get; init; } + public string? Owner { get; init; } public string? Repo { get; init; } @@ -183,9 +190,8 @@ public partial class ChangelogBundlingService( ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem) : null; - // Defaults applied when sourcing CDN entries and the org/branch are not otherwise resolvable. - private const string DefaultOwner = "elastic"; - private const string DefaultBranch = "main"; + private const string DefaultOwner = ChangelogEntrySourcing.DefaultOwner; + private const string DefaultBranch = ChangelogEntrySourcing.DefaultBranch; /// /// UTF-8 encoding without BOM for writing YAML files. @@ -268,10 +274,10 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle var authoringRepo = ChangelogRepoOwnerResolver.NormalizeRepo(input.Repo); var authoringOwner = ChangelogRepoOwnerResolver.ResolveOwner(input.Owner, input.Repo, DefaultOwner); var authoringBranch = string.IsNullOrWhiteSpace(input.Branch) ? DefaultBranch : input.Branch; - var useCdn = ShouldSourceFromCdn(authoringRepo, useLocalChangelogs: useLocalChangelogs, explicitDirectory: explicitDirectory); + var useCdn = ChangelogEntrySourcing.ShouldSourceFromCdn(authoringRepo, useLocalChangelogs: useLocalChangelogs, explicitDirectory: explicitDirectory); // Commit-range mode replaces the filter pipeline: the PR list is derived from git and - // each PR's entry is sourced pool-first with PR-metadata fallback. + // each PR's entry is sourced pool-first (PR-metadata inferral is opt-in via --infer). if (!string.IsNullOrWhiteSpace(input.StartGitRef)) { var sourcing = new GitRangeSourcingContext @@ -595,6 +601,7 @@ private async Task BuildAndWriteBundle( string[]? mergedHideFeatures = null; string? profileDescription = null; var profileSuppressReleaseDate = false; + var inferMissingChangelogs = input.InferMissingChangelogs; if (config?.Bundle?.Profiles != null && config.Bundle.Profiles.TryGetValue(input.Profile!, out var profile)) { @@ -653,6 +660,8 @@ private async Task BuildAndWriteBundle( branch = profile.Branch ?? config.Bundle.Branch; mergedHideFeatures = profile.HideFeatures?.Count > 0 ? [.. profile.HideFeatures] : null; profileSuppressReleaseDate = !(profile.ReleaseDates ?? config.Bundle.ReleaseDates ?? true); + inferMissingChangelogs = input.InferMissingChangelogs + || (profile.InferMissingChangelogs ?? config.Bundle.InferMissingChangelogs ?? false); // Handle profile-specific description with placeholder substitution var descriptionTemplate = profile.Description ?? config.Bundle.Description; @@ -700,7 +709,8 @@ private async Task BuildAndWriteBundle( Branch = branch, HideFeatures = mergedHideFeatures, Description = profileDescription, - SuppressReleaseDate = profileSuppressReleaseDate + SuppressReleaseDate = profileSuppressReleaseDate, + InferMissingChangelogs = inferMissingChangelogs }; } @@ -721,6 +731,12 @@ private static bool ValidateGitRefArguments(IDiagnosticsCollector collector, Bun return false; } + if (input.InferMissingChangelogs) + { + collector.EmitError(string.Empty, "--infer is only supported when bundling a git commit range (--start-git-ref/--end-git-ref)."); + return false; + } + return true; } @@ -811,8 +827,8 @@ private sealed record GitRangeSourcingContext /// /// Bundles a git commit range: resolves the range to a PR list (compare API + - /// associatedPullRequests), sources each PR's entry with the pool-first / - /// inferred-from-PR-metadata precedence, and reports PRs and commits that produced no entry. + /// associatedPullRequests), sources each PR's entry pool-first, optionally synthesizes + /// from PR metadata when inferral is on, and reports PRs and commits that produced no entry. /// In dry-run mode prints the run report instead of writing the bundle. /// private async Task BundleFromGitRange( @@ -857,7 +873,8 @@ private async Task BundleFromGitRange( Repo = sourcing.Repo, StartRef = input.StartGitRef!, EndRef = input.EndGitRef!, - FallbackProducts = input.OutputProducts + FallbackProducts = input.OutputProducts, + InferMissingChangelogs = input.InferMissingChangelogs }, ctx); var report = result.Report.ToMarkdown(); @@ -935,6 +952,12 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments ? input.SuppressReleaseDate : input.SuppressReleaseDate || !(config.Bundle.ReleaseDates ?? true); + // In profile mode, profile has already resolved inheritance (profile ?? bundle), so skip + // re-applying the bundle-level flag — otherwise profile false cannot override bundle true. + var inferMissingChangelogs = !string.IsNullOrWhiteSpace(input.Profile) + ? input.InferMissingChangelogs + : input.InferMissingChangelogs || (config.Bundle.InferMissingChangelogs ?? false); + return input with { Directory = directory, @@ -944,6 +967,7 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments Branch = branch, Description = description, SuppressReleaseDate = suppressReleaseDate, + InferMissingChangelogs = inferMissingChangelogs, LinkAllowRepos = config.Bundle.LinkAllowRepos }; } @@ -962,7 +986,7 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments var needsNetwork = hasReleaseVersion; var needsGithubToken = hasReleaseVersion; - // Commit-range bundling always needs the GitHub API (compare + GraphQL + PR metadata fallback). + // Commit-range bundling always needs the GitHub API (compare + GraphQL). if (!string.IsNullOrWhiteSpace(input.StartGitRef) || !string.IsNullOrWhiteSpace(input.EndGitRef)) { needsNetwork = true; @@ -1004,7 +1028,7 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments || input.ForceLocal; var explicitDirectory = !string.IsNullOrWhiteSpace(input.Directory); var authoringRepo = ChangelogRepoOwnerResolver.NormalizeRepo(input.Repo ?? profileDef?.Repo ?? config?.Bundle?.Repo); - if (ShouldSourceFromCdn(authoringRepo, useLocalChangelogs: useLocalChangelogs, explicitDirectory: explicitDirectory)) + if (ChangelogEntrySourcing.ShouldSourceFromCdn(authoringRepo, useLocalChangelogs: useLocalChangelogs, explicitDirectory: explicitDirectory)) needsNetwork = true; // Resolve output path — mirrors the logic in ProcessProfile + ApplyConfigDefaults. @@ -1152,14 +1176,6 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments return byName.Select(kv => (kv.Key, kv.Value)).ToList(); } - /// Gate for repo-scoped CDN entry sourcing: true when the authoring repo resolves, local sourcing is not forced (bundle.use_local_changelogs/--force-local/--directory), and a CDN base is configured. - private static bool ShouldSourceFromCdn(string? authoringRepo, bool useLocalChangelogs, bool explicitDirectory) - { - if (useLocalChangelogs || explicitDirectory || string.IsNullOrWhiteSpace(authoringRepo)) - return false; - return ChangelogCdn.ResolveBaseUri() is not null; - } - /// /// Selects the CDN-sourced entries whose file names were explicitly requested via --files / a /// path list. Every requested name must exist in the pool: the registry is the source of truth for diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogEntrySourcing.cs b/src/services/Elastic.Changelog/Bundling/ChangelogEntrySourcing.cs new file mode 100644 index 0000000000..9c5ba738cf --- /dev/null +++ b/src/services/Elastic.Changelog/Bundling/ChangelogEntrySourcing.cs @@ -0,0 +1,29 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Documentation.Configuration.ReleaseNotes; + +namespace Elastic.Changelog.Bundling; + +/// +/// Shared gate for repo-scoped CDN vs local changelog-entry sourcing. +/// Used by changelog bundle and changelog bundle-amend. +/// +internal static class ChangelogEntrySourcing +{ + public const string DefaultOwner = "elastic"; + public const string DefaultBranch = "main"; + + /// + /// True when the authoring repo resolves, local sourcing is not forced + /// (bundle.use_local_changelogs / --force-local / --directory), + /// and a CDN base is configured. + /// + public static bool ShouldSourceFromCdn(string? authoringRepo, bool useLocalChangelogs, bool explicitDirectory = false) + { + if (useLocalChangelogs || explicitDirectory || string.IsNullOrWhiteSpace(authoringRepo)) + return false; + return ChangelogCdn.ResolveBaseUri() is not null; + } +} diff --git a/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs b/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs index f4f4ed86fb..5b7913fc70 100644 --- a/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs +++ b/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs @@ -38,6 +38,13 @@ public record GitRangeEntryResolutionOptions /// to any product (typically the profile's output_products). Wildcards are ignored. /// public IReadOnlyList? FallbackProducts { get; init; } + + /// + /// When true, synthesize an in-memory entry from GitHub PR metadata if no matching changelog + /// YAML is found on the CDN (or in the local folder when local sourcing is forced). + /// When false (the default), unmatched PRs are warned and omitted like --prs. + /// + public bool InferMissingChangelogs { get; init; } } /// How a pull request's changelog entry was sourced for a commit-range bundle. @@ -46,6 +53,9 @@ public enum GitRangePrSourceKind /// A checked-in entry existed in the entry pool (CDN or local directory). Pool, + /// No pool entry and inferral is off; the PR is omitted (not notable / no YAML). + Unmatched, + /// Synthesized from PR metadata, including release-note text extracted from the PR body. InferredPrBody, @@ -97,7 +107,8 @@ public string ToMarkdown() { var source = row.Source switch { - GitRangePrSourceKind.Pool => "pool", + GitRangePrSourceKind.Pool => "cdn", + GitRangePrSourceKind.Unmatched => "no changelog", GitRangePrSourceKind.InferredPrBody => "inferred (PR body)", GitRangePrSourceKind.InferredTitle => "inferred (title)", GitRangePrSourceKind.Excluded => "excluded (rules)", @@ -129,12 +140,12 @@ public record GitRangeEntryResolutionResult } /// -/// Resolves the changelog entries for the pull requests of a commit range, applying the RFC's -/// sourcing precedence per PR: a checked-in entry from the pool (matched by file-name-derived PR -/// numbers — file names survive scrubbing — or by the entry's prs references) wins over an -/// entry synthesized from PR metadata via the same extraction path changelog add uses -/// (release-note text from the PR body, label-mapped type/areas/products); PRs whose metadata -/// cannot be fetched are reported as missing rather than silently dropped. +/// Resolves the changelog entries for the pull requests of a commit range. A checked-in entry from +/// the pool (matched by file-name-derived PR numbers — file names survive scrubbing — or by the +/// entry's prs references) wins. When +/// is true, a pool miss synthesizes an entry from PR metadata via the same extraction path +/// changelog add uses; otherwise the PR is warned and omitted like --prs. PRs whose +/// metadata cannot be fetched (inferral on) are reported as missing rather than silently dropped. /// public class GitRangeEntryResolver(IGitHubPrService prService, ILogger logger) { @@ -189,6 +200,13 @@ public async Task ResolveAsync( continue; } + if (!options.InferMissingChangelogs) + { + collector.EmitWarning(string.Empty, $"No changelog file found for PR: {pr.Url}"); + rows.Add(Row(pr, GitRangePrSourceKind.Unmatched)); + continue; + } + var (row, synthesized, failed) = await SynthesizeFromPrMetadata(collector, pr, config, options, ctx); rows.Add(row); if (synthesized != null) diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 1d4cd6df3d..bed5c01ead 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -573,9 +573,10 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// Branch whose CDN changelog entry pool (changelog/{org}/{repo}/{branch}/...) is sourced from. Falls back to bundle.branch or "main". This option is not supported in profile-based commands. The equivalent configuration options are bundle.branch or bundle.profiles.<name>.branch. /// Filter by pull request URLs (comma-separated), or a path to a newline-delimited file containing fully-qualified GitHub PR URLs. Can be specified multiple times. This option is not supported in profile-based commands. Pass a promotion report as the second or third positional argument instead, or set source: github_release on the profile. /// Filter by changelog YAML paths (comma-separated), or a path to a newline-delimited file containing changelog paths. Can be specified multiple times. When entries are sourced from the CDN, paths are matched to pool entries by file name and do not need to exist locally; with local sourcing (--force-local, --directory, or bundle.use_local_changelogs) the paths must exist on disk. This option is not supported in profile-based commands; pass a path list file as the second or third positional argument instead. - /// Start ref (exclusive) of a git commit range to bundle, for example the previously published endpoint ref. Must be provided together with --end-git-ref; the start ref is never inferred. The PR list is derived from the range itself (GitHub compare API + GraphQL associatedPullRequests), each PR's entry is sourced pool-first with PR-metadata fallback, and requires GITHUB_TOKEN. Supported in profile-based commands (for example, 'bundle serverless-release 2026-08-13 --start-git-ref abc123 --end-git-ref def456'); mutually exclusive with all other filter options. + /// Start ref (exclusive) of a git commit range to bundle, for example the previously published endpoint ref. Must be provided together with --end-git-ref; the start ref is never inferred. The PR list is derived from the range itself (GitHub compare API + GraphQL associatedPullRequests). Each PR's changelog YAML is sourced from the CDN (or the local folder when local sourcing is forced); unmatched PRs are warned and omitted unless --infer or bundle.infer_missing_changelogs is set. Requires GITHUB_TOKEN. Supported in profile-based commands (for example, 'bundle serverless-release 2026-08-13 --start-git-ref abc123 --end-git-ref def456'); mutually exclusive with all other filter options. /// End ref (inclusive) of the git commit range to bundle — the currently published endpoint ref. Must be provided together with --start-git-ref. Recorded in the bundle output as the git_ref metadata field. - /// Resolve the commit range and print the run report (resolved PR list with per-PR entry source: pool, inferred, or missing) as Markdown without writing a bundle. Only valid together with --start-git-ref/--end-git-ref. Supported in profile-based commands. + /// Resolve the commit range and print the run report (resolved PR list with per-PR entry source: pool, no changelog, inferred, or missing) as Markdown without writing a bundle. Only valid together with --start-git-ref/--end-git-ref. Supported in profile-based commands. + /// When bundling a git commit range, synthesize in-memory changelog entries from GitHub PR metadata for PRs with no matching changelog YAML on the CDN (or in the local folder when using --force-local). Default: unmatched PRs are warned and omitted like --prs. Equivalent to bundle.infer_missing_changelogs: true without editing config. Allowed in profile-based commands. Only valid together with --start-git-ref/--end-git-ref. /// Force local entry sourcing for this run (equivalent to bundle.use_local_changelogs: true without editing config). Allowed in profile-based commands. /// GitHub repository name for PR/issue numbers or --release-version. Falls back to bundle.repo or the product ID. This option is not supported in profile-based commands. The equivalent configuration options are bundle.repo or bundle.profiles.<name>.repo. /// URL or file path to a promotion report; extracts PR URLs as the filter. This option is not supported in profile-based commands. Pass the report as the second or third positional argument instead. @@ -610,6 +611,7 @@ public async Task Bundle( string? startGitRef = null, string? endGitRef = null, bool dryRun = false, + bool infer = false, CancellationToken ct = default ) { @@ -634,6 +636,12 @@ public async Task Bundle( return 1; } + if (infer && !isGitRefMode) + { + collector.EmitError(string.Empty, "--infer is only supported when bundling a git commit range (--start-git-ref/--end-git-ref)."); + return 1; + } + if (isGitRefMode && releaseVersion != null) { collector.EmitError(string.Empty, "--start-git-ref/--end-git-ref and --release-version are mutually exclusive."); @@ -957,7 +965,8 @@ public async Task Bundle( SuppressReleaseDate = noReleaseDate, StartGitRef = startGitRef, EndGitRef = endGitRef, - DryRun = dryRun + DryRun = dryRun, + InferMissingChangelogs = infer }; serviceInvoker.AddCommand(service, input, @@ -1332,9 +1341,10 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c /// Append or exclude changelog entries in a published bundle without modifying it. /// Creates an immutable .amend-N.yaml sidecar file alongside the original bundle. /// Required: Path to the original bundle file to amend - /// Optional: Changelog YAML paths to add. Repeat --add or pass a comma-separated list in one value (for example, --add "file1.yaml,file2.yaml"). Supports tilde (~) expansion and relative paths. - /// Optional: Changelog YAML paths to exclude from the effective bundle. Repeat --remove or pass a comma-separated list in one value. Supports tilde (~) expansion and relative paths. - /// Optional: When removing, match by file name even if the bundle checksum differs from the file on disk. + /// Optional: Changelog YAML paths to add. Repeat --add or pass a comma-separated list in one value (for example, --add "file1.yaml,file2.yaml"). Supports tilde (~) expansion and relative paths. When entries are sourced from the CDN (the default when bundle.repo or the parent bundle's repo resolves), paths are matched by file name and do not need to exist locally; with local sourcing (--force-local or bundle.use_local_changelogs) the paths must exist on disk. + /// Optional: Changelog YAML paths to exclude from the effective bundle. Repeat --remove or pass a comma-separated list in one value. Supports tilde (~) expansion and relative paths. When entries are sourced from the CDN, paths are matched by file name and do not need to exist locally; with local sourcing the paths must exist on disk unless --force is used to exclude by file name. + /// Optional: When removing, match by file name even if the bundle checksum differs from the sourced changelog, or when no YAML can be sourced (inferred git-ref entries). + /// Optional: Force local entry sourcing for this run (equivalent to bundle.use_local_changelogs: true without editing config). /// Optional: Preview changes without writing an amend file. [NoOptionsInjection] public async Task BundleAmend( @@ -1342,6 +1352,7 @@ public async Task BundleAmend( string[]? add = null, string[]? remove = null, bool force = false, + bool forceLocal = false, bool dryRun = false, CancellationToken ct = default ) @@ -1375,6 +1386,7 @@ public async Task BundleAmend( AddFiles = normalizedAddFiles, RemoveFiles = normalizedRemoveFiles, Force = force, + ForceLocal = forceLocal, DryRun = dryRun }; diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendCdnTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendCdnTests.cs new file mode 100644 index 0000000000..6b53ff5e47 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendCdnTests.cs @@ -0,0 +1,258 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Net; +using AwesomeAssertions; +using Elastic.Changelog.Bundling; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.ReleaseNotes; +using Elastic.Documentation.Diagnostics; + +namespace Elastic.Changelog.Tests.Changelogs; + +public class BundleAmendCdnTests(ITestOutputHelper output) : ChangelogTestBase(output) +{ + // language=yaml + private const string ExistingEntry = + """ + title: Existing feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + """; + + // language=yaml + private const string LateEntry = + """ + title: Late addition + type: enhancement + products: + - product: elasticsearch + target: 9.3.0 + """; + + // language=yaml + private const string ChangedEntry = + """ + title: Changed on CDN + type: feature + products: + - product: elasticsearch + target: 9.3.0 + """; + + [Fact] + public async Task Amend_Remove_CdnPath_NoLocalFile_ExcludesByChecksum() + { + var bundlePath = await WriteParentBundleAsync("existing.yaml", ExistingEntry); + var handler = CdnHandler(("existing.yaml", ExistingEntry)); + var service = ServiceWithCdn(handler); + + var result = await service.AmendBundle(Collector, new AmendBundleArguments + { + BundlePath = bundlePath, + RemoveFiles = ["/changelog/elastic/elasticsearch/main/existing.yaml"] + }, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/main/registry.json"); + handler.RequestedPaths.Should().Contain(p => p.EndsWith("/existing.yaml", StringComparison.Ordinal)); + var amend = await ReadSingleAmendAsync(bundlePath); + amend.Should().Contain("exclude-entries:"); + amend.Should().Contain("name: existing.yaml"); + } + + [Fact] + public async Task Amend_Add_CdnBasename_NoLocalFile_EmbedsEntry() + { + var bundlePath = await WriteParentBundleAsync("existing.yaml", ExistingEntry); + var handler = CdnHandler(("existing.yaml", ExistingEntry), ("late.yaml", LateEntry)); + var service = ServiceWithCdn(handler); + + var result = await service.AmendBundle(Collector, new AmendBundleArguments + { + BundlePath = bundlePath, + AddFiles = ["late.yaml"] + }, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + handler.RequestedPaths.Should().NotContain(p => p.EndsWith("/existing.yaml", StringComparison.Ordinal), + "only requested names are fetched"); + var amend = await ReadSingleAmendAsync(bundlePath); + amend.Should().Contain("title: Late addition"); + amend.Should().Contain("name: late.yaml"); + } + + [Fact] + public async Task Amend_Add_CdnPoolMissingName_Fails() + { + var bundlePath = await WriteParentBundleAsync("existing.yaml", ExistingEntry); + var service = ServiceWithCdn(CdnHandler(("existing.yaml", ExistingEntry))); + + var result = await service.AmendBundle(Collector, new AmendBundleArguments + { + BundlePath = bundlePath, + AddFiles = ["never-uploaded.yaml"] + }, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("never-uploaded.yaml") && d.Message.Contains("CDN pool")); + } + + [Fact] + public async Task Amend_ForceLocal_DoesNotHitCdn() + { + var bundlePath = await WriteParentBundleAsync("existing.yaml", ExistingEntry); + var localDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(localDir); + var localFile = FileSystem.Path.Join(localDir, "late.yaml"); + await FileSystem.File.WriteAllTextAsync(localFile, LateEntry, TestContext.Current.CancellationToken); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + var service = ServiceWithCdn(handler); + + var result = await service.AmendBundle(Collector, new AmendBundleArguments + { + BundlePath = bundlePath, + AddFiles = [localFile], + ForceLocal = true + }, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + handler.RequestedPaths.Should().BeEmpty("--force-local must not reach the CDN"); + var amend = await ReadSingleAmendAsync(bundlePath); + amend.Should().Contain("title: Late addition"); + } + + [Fact] + public async Task Amend_Remove_CdnChecksumMismatch_WithoutForce_Fails() + { + var bundlePath = await WriteParentBundleAsync("existing.yaml", ExistingEntry); + var service = ServiceWithCdn(CdnHandler(("existing.yaml", ChangedEntry))); + + var result = await service.AmendBundle(Collector, new AmendBundleArguments + { + BundlePath = bundlePath, + RemoveFiles = ["existing.yaml"] + }, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("different checksum")); + } + + [Fact] + public async Task Amend_Remove_InferredEntry_ForceWithoutDummy_ExcludesByName() + { + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(bundleDir); + var bundlePath = FileSystem.Path.Join(bundleDir, "bundle.yaml"); + await FileSystem.File.WriteAllTextAsync(bundlePath, """ + products: + - product: elasticsearch + target: 9.3.0 + repo: elasticsearch + owner: elastic + entries: + - file: + name: 300.yaml + checksum: inferred-placeholder + type: enhancement + title: Inferred from PR + """, TestContext.Current.CancellationToken); + + var handler = CdnHandler(("existing.yaml", ExistingEntry)); + var service = ServiceWithCdn(handler); + + var result = await service.AmendBundle(Collector, new AmendBundleArguments + { + BundlePath = bundlePath, + RemoveFiles = ["300.yaml"], + Force = true + }, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/main/registry.json"); + handler.RequestedPaths.Should().NotContain(p => p.EndsWith("/300.yaml", StringComparison.Ordinal)); + var amend = await ReadSingleAmendAsync(bundlePath); + amend.Should().Contain("name: 300.yaml"); + amend.Should().Contain("exclude-entries:"); + } + + private ChangelogBundleAmendService ServiceWithCdn(StubHandler handler) + { + var fetcher = new CdnChangelogEntryFetcher(LoggerFactory, handler, sleep: (_, _) => Task.CompletedTask); + return new ChangelogBundleAmendService(LoggerFactory, FileSystem, entryFetcher: fetcher); + } + + private async Task WriteParentBundleAsync(string fileName, string changelogYaml) + { + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(bundleDir); + var bundlePath = FileSystem.Path.Join(bundleDir, "bundle.yaml"); + var checksum = ComputeSha1(changelogYaml); + await FileSystem.File.WriteAllTextAsync(bundlePath, $""" + products: + - product: elasticsearch + target: 9.3.0 + repo: elasticsearch + owner: elastic + entries: + - file: + name: {fileName} + checksum: {checksum} + type: feature + title: Existing feature + """, TestContext.Current.CancellationToken); + return bundlePath; + } + + private async Task ReadSingleAmendAsync(string bundlePath) + { + var amendFiles = ChangelogBundleAmendService.DiscoverAmendFiles(FileSystem, bundlePath); + amendFiles.Should().ContainSingle(); + return await FileSystem.File.ReadAllTextAsync(amendFiles[0], TestContext.Current.CancellationToken); + } + + private static StubHandler CdnHandler(params (string FileName, string Yaml)[] entries) + { + var filesJson = string.Join(", ", entries.Select(e => $"{{ \"file\": \"{e.FileName}\" }}")); + var registry = $"{{ \"schema_version\": 1, \"product\": \"elasticsearch\", \"bundles\": [ {filesJson} ] }}"; + var byName = entries.ToDictionary(e => e.FileName, e => e.Yaml, StringComparer.Ordinal); + return new StubHandler(req => + { + var path = req.RequestUri!.AbsolutePath; + if (path.EndsWith("/registry.json", StringComparison.Ordinal)) + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(registry, System.Text.Encoding.UTF8, "application/json") + }; + foreach (var (fileName, yaml) in byName) + { + if (path.EndsWith("/" + fileName, StringComparison.Ordinal)) + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(yaml, System.Text.Encoding.UTF8, "text/yaml") + }; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + } + + private sealed class StubHandler(Func responder) : HttpMessageHandler + { + public List RequestedPaths { get; } = []; + + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestedPaths.Add(request.RequestUri!.AbsolutePath); + return responder(request); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(Send(request, cancellationToken)); + } +} diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendEndToEndTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendEndToEndTests.cs index 76c4965051..ef479c5a65 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendEndToEndTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendEndToEndTests.cs @@ -88,7 +88,8 @@ await FileSystem.File.WriteAllTextAsync(parentPath, $""" { BundlePath = parentPath, AddFiles = [addedFile], - RemoveFiles = [retractedFile] + RemoveFiles = [retractedFile], + ForceLocal = true }, ct); amendResult.Should().BeTrue(); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs index 0faa715f48..c363de8b79 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs @@ -318,7 +318,8 @@ public async Task AmendBundle_Add_CopiesParentProductsIntoAmend() var input = new AmendBundleArguments { BundlePath = bundlePath, - AddFiles = [newFile] + AddFiles = [newFile], + ForceLocal = true }; var result = await Service.AmendBundle(amendCollector, input, ct); @@ -355,7 +356,8 @@ public async Task AmendBundle_Remove_CopiesParentProductsIntoAmend() var input = new AmendBundleArguments { BundlePath = bundlePath, - RemoveFiles = [changelogFile] + RemoveFiles = [changelogFile], + ForceLocal = true }; var result = await Service.AmendBundle(amendCollector, input, ct); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs index a3c2373a27..b3d4c01927 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs @@ -15,9 +15,8 @@ namespace Elastic.Changelog.Tests.Changelogs; /// /// Tests for commit-range bundling (changelog bundle --start-git-ref --end-git-ref): -/// the PR list is derived from the range, each PR's entry follows the pool-first / -/// inferred-from-PR-metadata precedence, the bundle records git_ref, and dry-run -/// reports without writing. +/// the PR list is derived from the range, each PR's entry is sourced from the pool (inferral from +/// PR metadata is opt-in), the bundle records git_ref, and dry-run reports without writing. /// public class BundleGitRefTests(ITestOutputHelper output) : ChangelogTestBase(output) { @@ -87,11 +86,18 @@ private static IGitHubCommitRangeService RangeService(params int[] prNumbers) return service; } - private async Task WriteProfileConfig(string outputDir) + private async Task WriteProfileConfig( + string outputDir, + bool inferMissingChangelogs = false, + bool? bundleInferMissingChangelogs = null) { + var profileInferYaml = inferMissingChangelogs.ToString().ToLowerInvariant(); + var bundleInferLine = bundleInferMissingChangelogs is { } bundleInfer + ? $"\n infer_missing_changelogs: {bundleInfer.ToString().ToLowerInvariant()}" + : ""; // language=yaml var configContent = - """ + $$""" pivot: types: feature: ">feature" @@ -100,10 +106,11 @@ private async Task WriteProfileConfig(string outputDir) bundle: output_directory: PLACEHOLDER repo: widget - owner: elastic + owner: elastic{{bundleInferLine}} profiles: promotion: output_products: "cloud-hosted {version}" + infer_missing_changelogs: {{profileInferYaml}} """.Replace("PLACEHOLDER", outputDir); var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); @@ -149,7 +156,8 @@ public async Task ProfileMode_PoolFirstWithInferredFallback_WritesBundleWithGitR ProfileArgument = "2026-08-13", Config = configPath, StartGitRef = StartRef, - EndGitRef = EndRef + EndGitRef = EndRef, + InferMissingChangelogs = true }; var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -310,7 +318,8 @@ public async Task InferredEntry_NoTypeLabel_DefaultsToOtherWithWarning() ProfileArgument = "2026-08-13", Config = configPath, StartGitRef = StartRef, - EndGitRef = EndRef + EndGitRef = EndRef, + InferMissingChangelogs = true }; var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -366,6 +375,7 @@ public void GitRangeReport_ToMarkdown_ListsPrSourcesAndOrphanCommits() Rows = [ new GitRangePrReportRow { Number = 100, Url = "https://github.com/elastic/widget/pull/100", Source = GitRangePrSourceKind.Pool, EntryFileNames = ["100.yaml"] }, + new GitRangePrReportRow { Number = 250, Url = "https://github.com/elastic/widget/pull/250", Source = GitRangePrSourceKind.Unmatched }, new GitRangePrReportRow { Number = 300, Url = "https://github.com/elastic/widget/pull/300", Source = GitRangePrSourceKind.InferredPrBody, EntryFileNames = ["300.yaml"] }, new GitRangePrReportRow { Number = 400, Url = "https://github.com/elastic/widget/pull/400", Source = GitRangePrSourceKind.Missing } ], @@ -375,12 +385,185 @@ public void GitRangeReport_ToMarkdown_ListsPrSourcesAndOrphanCommits() var markdown = report.ToMarkdown(); markdown.Should().Contain($"`{StartRef}..{EndRef}`"); - markdown.Should().Contain("| [#100](https://github.com/elastic/widget/pull/100) | pool | `100.yaml` |"); + markdown.Should().Contain("| [#100](https://github.com/elastic/widget/pull/100) | cdn | `100.yaml` |"); + markdown.Should().Contain("| [#250](https://github.com/elastic/widget/pull/250) | no changelog | — |"); markdown.Should().Contain("| [#300](https://github.com/elastic/widget/pull/300) | inferred (PR body) | `300.yaml` |"); markdown.Should().Contain("| [#400](https://github.com/elastic/widget/pull/400) | missing | — |"); markdown.Should().Contain("- `deadbeef`"); } + [Fact] + public async Task ProfileMode_PoolOnlyDefault_OmitsUnmatchedPrsWithWarning() + { + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + var configPath = await WriteProfileConfig(outputDir); + + var prService = A.Fake(); + var service = Service(PoolHandler(), RangeService(100, 200, 300), prService); + + var input = new BundleChangelogsArguments + { + Profile = "promotion", + ProfileArgument = "2026-08-13", + Config = configPath, + StartGitRef = StartRef, + EndGitRef = EndRef + }; + + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + A.CallTo(prService).MustNotHaveHappened(); + + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Warning && + d.Message.Contains("No changelog file found for PR: https://github.com/elastic/widget/pull/300")); + + var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + outputFiles.Should().ContainSingle(); + var bundle = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); + bundle.Should().Contain("Faster hosted search"); + bundle.Should().Contain("Sturdier snapshots"); + bundle.Should().NotContain("300.yaml"); + bundle.Should().NotContain("Sharper autocomplete"); + } + + [Fact] + public async Task ProfileMode_PoolOnly_AllUnmatched_FailsEmptyFilter() + { + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + var configPath = await WriteProfileConfig(outputDir); + + var service = Service(PoolHandler(), RangeService(300)); + + var input = new BundleChangelogsArguments + { + Profile = "promotion", + ProfileArgument = "2026-08-13", + Config = configPath, + StartGitRef = StartRef, + EndGitRef = EndRef + }; + + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("No changelog entries could be resolved")); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Warning && + d.Message.Contains("No changelog file found for PR: https://github.com/elastic/widget/pull/300")); + FileSystem.Directory.GetFiles(outputDir, "*.yaml").Should().BeEmpty(); + } + + [Fact] + public async Task ProfileMode_InferMissingChangelogsYaml_SynthesizesUnmatchedPr() + { + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + var configPath = await WriteProfileConfig(outputDir, inferMissingChangelogs: true); + + var prService = A.Fake(); + _ = A.CallTo(() => prService.FetchPrInfoAsync("https://github.com/elastic/widget/pull/300", A._, A._, A._)) + .Returns(new GitHubPrInfo + { + Title = "Sharper autocomplete", + Body = "Some context.\n\n## Release Note\nAutocomplete now ranks recent indices first.\n\nInternal details.", + Labels = [">feature"], + LinkedIssues = [] + }); + + var service = Service(PoolHandler(), RangeService(100, 300), prService); + + var input = new BundleChangelogsArguments + { + Profile = "promotion", + ProfileArgument = "2026-08-13", + Config = configPath, + StartGitRef = StartRef, + EndGitRef = EndRef + }; + + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + var bundle = await FileSystem.File.ReadAllTextAsync( + FileSystem.Directory.GetFiles(outputDir, "*.yaml").Single(), + TestContext.Current.CancellationToken); + bundle.Should().Contain("Faster hosted search"); + bundle.Should().Contain("Sharper autocomplete"); + bundle.Should().Contain("name: 300.yaml"); + } + + [Fact] + public async Task ProfileMode_ProfileFalseOverridesBundleTrue_OmitsUnmatchedPr() + { + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + var configPath = await WriteProfileConfig( + outputDir, + inferMissingChangelogs: false, + bundleInferMissingChangelogs: true); + + var prService = A.Fake(); + _ = A.CallTo(() => prService.FetchPrInfoAsync("https://github.com/elastic/widget/pull/300", A._, A._, A._)) + .Returns(new GitHubPrInfo + { + Title = "Sharper autocomplete", + Body = "Some context.\n\n## Release Note\nAutocomplete now ranks recent indices first.\n\nInternal details.", + Labels = [">feature"], + LinkedIssues = [] + }); + + var service = Service(PoolHandler(), RangeService(100, 300), prService); + + var input = new BundleChangelogsArguments + { + Profile = "promotion", + ProfileArgument = "2026-08-13", + Config = configPath, + StartGitRef = StartRef, + EndGitRef = EndRef + }; + + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Warning && + d.Message.Contains("No changelog file found for PR: https://github.com/elastic/widget/pull/300")); + A.CallTo(prService).MustNotHaveHappened(); + + var bundle = await FileSystem.File.ReadAllTextAsync( + FileSystem.Directory.GetFiles(outputDir, "*.yaml").Single(), + TestContext.Current.CancellationToken); + bundle.Should().Contain("Faster hosted search"); + bundle.Should().NotContain("300.yaml"); + bundle.Should().NotContain("Sharper autocomplete"); + } + + [Fact] + public async Task InferWithoutGitRef_Errors() + { + var service = Service(PoolHandler(), RangeService()); + + var input = new BundleChangelogsArguments + { + Repo = "widget", + All = true, + InferMissingChangelogs = true + }; + + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("--infer is only supported")); + } + [Theory] [InlineData("100.yaml", new[] { 100 })] [InlineData("100-200.yaml", new[] { 100, 200 })] diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs index f0b1af079f..4b3933996c 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs @@ -1824,4 +1824,33 @@ public async Task LoadChangelogConfiguration_UseLocalChangelogs_True_Parses() Collector.Errors.Should().Be(0); config.Bundle!.UseLocalChangelogs.Should().BeTrue(); } + + [Fact] + public async Task LoadChangelogConfiguration_InferMissingChangelogs_ParsesBundleAndProfile() + { + var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + + // language=yaml + var configContent = + """ + bundle: + infer_missing_changelogs: false + profiles: + promotion: + infer_missing_changelogs: true + inherit: + output: "inherit.yaml" + """; + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var config = await configLoader.LoadChangelogConfiguration(Collector, configPath, TestContext.Current.CancellationToken); + + config.Should().NotBeNull(); + Collector.Errors.Should().Be(0); + config.Bundle!.InferMissingChangelogs.Should().BeFalse(); + config.Bundle.Profiles!["promotion"].InferMissingChangelogs.Should().BeTrue(); + config.Bundle.Profiles["inherit"].InferMissingChangelogs.Should().BeNull(); + } } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs index 17c16085cf..4808ba45f6 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs @@ -78,7 +78,7 @@ public async Task WaitForDrain_WithoutStartAsync_ThrowsImmediately() var collector = new DiagnosticsCollector([]); collector.EmitWarning(string.Empty, "queued"); - Func act = () => ((IDiagnosticsCollector)collector).WaitForDrain(); + var act = () => ((IDiagnosticsCollector)collector).WaitForDrain(); _ = await act.Should().ThrowAsync(); } diff --git a/tests/Elastic.Changelog.Tests/TestHelpers.cs b/tests/Elastic.Changelog.Tests/TestHelpers.cs index f8aafe19ed..c976572c56 100644 --- a/tests/Elastic.Changelog.Tests/TestHelpers.cs +++ b/tests/Elastic.Changelog.Tests/TestHelpers.cs @@ -46,7 +46,11 @@ private sealed class NullScope : IDisposable public void Dispose() { } } - public IDisposable BeginScope(TState state) where TState : notnull => new NullScope(); + public IDisposable BeginScope(TState state) where TState : notnull + { + _ = state; + return new NullScope(); + } public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Trace; @@ -58,15 +62,23 @@ public class TestLoggerProvider(ITestOutputHelper? output) : ILoggerProvider { public void Dispose() => GC.SuppressFinalize(this); - public ILogger CreateLogger(string categoryName) => new TestLogger(output); + public ILogger CreateLogger(string categoryName) + { + _ = categoryName; + return new TestLogger(output); + } } public class TestLoggerFactory(ITestOutputHelper? output) : ILoggerFactory { public void Dispose() => GC.SuppressFinalize(this); - public void AddProvider(ILoggerProvider provider) { } + public void AddProvider(ILoggerProvider provider) => _ = provider; - public ILogger CreateLogger(string categoryName) => new TestLogger(output); + public ILogger CreateLogger(string categoryName) + { + _ = categoryName; + return new TestLogger(output); + } } diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogEntryFetcherTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogEntryFetcherTests.cs index 7d2f182786..bbd2829796 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogEntryFetcherTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogEntryFetcherTests.cs @@ -188,6 +188,71 @@ public async Task FetchAsync_UnsafeFileName_EmitsWarningAndSkips() warnings.Should().ContainSingle().Which.Should().Contain("escape.yaml"); } + [Fact] + public async Task FetchNamedAsync_DownloadsOnlyRequestedEntries() + { + var handler = new StubHandler(req => + req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) + ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "keep.yaml" }, { "file": "skip.yaml" } ] }""") + : Yaml(SampleEntry)); + var (errors, warnings, emitError, _) = Diagnostics(); + + using var fetcher = CreateFetcher(handler); + var result = await fetcher.FetchNamedAsync( + BaseUri, "elastic", "elasticsearch", "main", ["keep.yaml"], + emitError, TestContext.Current.CancellationToken); + + errors.Should().BeEmpty(); + warnings.Should().BeEmpty(); + result.Should().NotBeNull(); + result!.Value.Entries.Select(e => e.FileName).Should().BeEquivalentTo("keep.yaml"); + result.Value.MissingFromRegistry.Should().BeEmpty(); + handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/main/registry.json"); + handler.RequestedPaths.Should().Contain(p => p.EndsWith("/keep.yaml", StringComparison.Ordinal)); + handler.RequestedPaths.Should().NotContain(p => p.EndsWith("/skip.yaml", StringComparison.Ordinal)); + } + + [Fact] + public async Task FetchNamedAsync_NameNotInRegistry_ReportsMissingWithoutFetchingIt() + { + var handler = new StubHandler(req => + req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) + ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "keep.yaml" } ] }""") + : Yaml(SampleEntry)); + var (errors, _, emitError, _) = Diagnostics(); + + using var fetcher = CreateFetcher(handler); + var result = await fetcher.FetchNamedAsync( + BaseUri, "elastic", "elasticsearch", "main", ["never-uploaded.yaml"], + emitError, TestContext.Current.CancellationToken); + + errors.Should().BeEmpty(); + result.Should().NotBeNull(); + result!.Value.Entries.Should().BeEmpty(); + result.Value.MissingFromRegistry.Should().Equal("never-uploaded.yaml"); + handler.RequestedPaths.Should().NotContain(p => p.EndsWith("/never-uploaded.yaml", StringComparison.Ordinal)); + } + + [Fact] + public async Task FetchNamedAsync_ListedEntryMissing_EmitsError() + { + var handler = new StubHandler(req => + { + if (req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal)) + return Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "1-a.yaml" } ] }"""); + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + var (errors, _, emitError, _) = Diagnostics(); + + using var fetcher = CreateFetcher(handler, maxAttempts: 2); + var result = await fetcher.FetchNamedAsync( + BaseUri, "elastic", "elasticsearch", "main", ["1-a.yaml"], + emitError, TestContext.Current.CancellationToken); + + result.Should().BeNull(); + errors.Should().ContainSingle().Which.Should().Contain("1-a.yaml"); + } + private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") };