From 2f89f97a540ea0513fc8a5c8524307f7b9e24b89 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 18:48:27 +0000 Subject: [PATCH 1/4] Initial plan From 50159aa616411f748934d81f3ef2a1d1fe87f350 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 18:52:42 +0000 Subject: [PATCH 2/4] Add changelog migration script and focused unit tests Agent-Logs-Url: https://github.com/envoyproxy/envoy/sessions/048e4cab-8f6a-43dc-aa23-7ba23335c567 Co-authored-by: phlax <454682+phlax@users.noreply.github.com> --- tools/changelogs/migrate_to_per_entry.py | 115 ++++++++++++++++++ tools/changelogs/migrate_to_per_entry_test.py | 102 ++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 tools/changelogs/migrate_to_per_entry.py create mode 100644 tools/changelogs/migrate_to_per_entry_test.py diff --git a/tools/changelogs/migrate_to_per_entry.py b/tools/changelogs/migrate_to_per_entry.py new file mode 100644 index 0000000000000..dc169eb853307 --- /dev/null +++ b/tools/changelogs/migrate_to_per_entry.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 + +import argparse +import pathlib +import re +from collections.abc import Iterable + +import yaml + + +MAX_SLUG_LENGTH = 40 +SLUG_SOURCE_LENGTH = 60 +ENTRY_SEPARATOR = "__" + + +def _truncate_slug(slug: str, max_length: int = MAX_SLUG_LENGTH) -> str: + if len(slug) <= max_length: + return slug + truncated = slug[:max_length].rstrip("-") + if "-" in truncated: + candidate = truncated.rsplit("-", 1)[0].rstrip("-") + if candidate: + return candidate + return truncated + + +def _base_slug(change: str) -> str: + text = change.strip() + if not text: + return "entry" + first_sentence = text.split(". ", 1)[0] + slug_source = first_sentence if len(first_sentence) <= SLUG_SOURCE_LENGTH else text[ + :SLUG_SOURCE_LENGTH] + normalized = re.sub(r"[^a-z0-9]+", "-", slug_source.lower()).strip("-") + if not normalized: + normalized = "entry" + return _truncate_slug(normalized) + + +def _encode_area(area: str) -> str: + return area.replace("/", "~") + + +def _write_area_file(path: pathlib.Path, change: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"{change.rstrip(chr(10))}\n") + + +def _area_metadata(areas: Iterable[str]) -> str: + header = ( + "# NB: areas listed here are the canonical set accepted by per-entry changelog\n" + "# filenames in changelogs/current/\n" + ) + area_dict = {area: {"title": area} for area in sorted(set(areas))} + if not area_dict: + return f"{header}\n" + return f"{header}\n{yaml.safe_dump(area_dict, sort_keys=False)}" + + +def migrate(project_root: pathlib.Path) -> None: + changelogs_dir = project_root / "changelogs" + current_yaml_path = changelogs_dir / "current.yaml" + sections_yaml_path = changelogs_dir / "sections.yaml" + current_entries_dir = changelogs_dir / "current" + areas_yaml_path = changelogs_dir / "areas.yaml" + + current_data = yaml.safe_load(current_yaml_path.read_text()) or {} + sections_data = yaml.safe_load(sections_yaml_path.read_text()) or {} + + date = current_data.get("date", "Pending") + slug_counts: dict[tuple[str, str, str], int] = {} + areas: list[str] = [] + + for section, section_entries in current_data.items(): + if section == "date": + continue + if section not in sections_data: + raise ValueError(f"Unknown section '{section}' found in {current_yaml_path}") + if not section_entries: + continue + for entry in section_entries: + area = entry["area"] + change = entry["change"] + if area not in areas: + areas.append(area) + area_encoded = _encode_area(area) + slug = _base_slug(change) + slug_key = (section, area_encoded, slug) + slug_counts[slug_key] = slug_counts.get(slug_key, 0) + 1 + count = slug_counts[slug_key] + if count > 1: + slug = f"{slug}-{count}" + entry_path = current_entries_dir / section / f"{area_encoded}{ENTRY_SEPARATOR}{slug}.rst" + _write_area_file(entry_path, change) + + current_yaml_path.write_text(yaml.safe_dump({"date": date}, sort_keys=False)) + areas_yaml_path.write_text(_area_metadata(areas)) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Migrate changelogs/current.yaml to changelogs/current/
/__.rst" + ) + parser.add_argument( + "--root", + type=pathlib.Path, + default=pathlib.Path(__file__).resolve().parents[2], + help="Envoy repository root path", + ) + args = parser.parse_args() + migrate(args.root) + + +if __name__ == "__main__": + main() diff --git a/tools/changelogs/migrate_to_per_entry_test.py b/tools/changelogs/migrate_to_per_entry_test.py new file mode 100644 index 0000000000000..73a059a489156 --- /dev/null +++ b/tools/changelogs/migrate_to_per_entry_test.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 + +import pathlib +import subprocess +import sys +import tempfile +import textwrap +import unittest + +import yaml + + +SCRIPT_PATH = pathlib.Path(__file__).with_name("migrate_to_per_entry.py") + + +class MigrateToPerEntryTest(unittest.TestCase): + + def _write_repo_files(self, root: pathlib.Path, date_value: str = "Pending") -> None: + changelogs_dir = root / "changelogs" + changelogs_dir.mkdir(parents=True, exist_ok=True) + (changelogs_dir / "sections.yaml").write_text(textwrap.dedent("""\ + bug_fixes: + title: Bug fixes + new_features: + title: New features + """)) + (changelogs_dir / "current.yaml").write_text( + textwrap.dedent(f"""\ + date: {date_value} + bug_fixes: + - area: router + change: | + Fix issue in router. Additional details. + - area: router + change: | + Fix issue in router. Another detail. + new_features: + - area: extensions/filters/http/oauth2 + change: | + Add nested area support for oauth2 filter feature and behavior that goes beyond sixty characters for slug input. + Keep second line as-is. + """)) + + def test_migration_generates_entry_files_and_rewrites_yaml(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = pathlib.Path(tmpdir) + self._write_repo_files(root) + + subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--root", str(root)], + check=True, + ) + + changelogs_dir = root / "changelogs" + self.assertEqual((changelogs_dir / "current.yaml").read_text(), "date: Pending\n") + + router_entry = changelogs_dir / "current" / "bug_fixes" / "router__fix-issue-in-router.rst" + router_entry_2 = changelogs_dir / "current" / "bug_fixes" / "router__fix-issue-in-router-2.rst" + nested_entry = ( + changelogs_dir + / "current" + / "new_features" + / "extensions~filters~http~oauth2__add-nested-area-support-for-oauth2.rst") + + self.assertTrue(router_entry.exists()) + self.assertTrue(router_entry_2.exists()) + self.assertTrue(nested_entry.exists()) + + self.assertEqual(router_entry.read_text(), "Fix issue in router. Additional details.\n") + self.assertEqual(router_entry_2.read_text(), "Fix issue in router. Another detail.\n") + self.assertEqual( + nested_entry.read_text(), + "Add nested area support for oauth2 filter feature and behavior that goes beyond sixty characters for slug input.\n" + "Keep second line as-is.\n") + + expected_areas = textwrap.dedent("""\ + # NB: areas listed here are the canonical set accepted by per-entry changelog + # filenames in changelogs/current/ + + extensions/filters/http/oauth2: + title: extensions/filters/http/oauth2 + router: + title: router + """) + self.assertEqual((changelogs_dir / "areas.yaml").read_text(), expected_areas) + + def test_migration_preserves_non_pending_date(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = pathlib.Path(tmpdir) + self._write_repo_files(root, date_value="January 1, 2026") + + subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--root", str(root)], + check=True, + ) + + data = yaml.safe_load((root / "changelogs" / "current.yaml").read_text()) + self.assertEqual(data, {"date": "January 1, 2026"}) + + +if __name__ == "__main__": + unittest.main() From d02740597ae908f8bdae55a305feade8ed780854 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 18:56:24 +0000 Subject: [PATCH 3/4] Refine changelog migration script validation and tests Agent-Logs-Url: https://github.com/envoyproxy/envoy/sessions/048e4cab-8f6a-43dc-aa23-7ba23335c567 Co-authored-by: phlax <454682+phlax@users.noreply.github.com> --- tools/changelogs/migrate_to_per_entry.py | 23 +++++++++++++++---- tools/changelogs/migrate_to_per_entry_test.py | 6 ++--- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/tools/changelogs/migrate_to_per_entry.py b/tools/changelogs/migrate_to_per_entry.py index dc169eb853307..a61f7a263cc50 100644 --- a/tools/changelogs/migrate_to_per_entry.py +++ b/tools/changelogs/migrate_to_per_entry.py @@ -29,8 +29,11 @@ def _base_slug(change: str) -> str: if not text: return "entry" first_sentence = text.split(". ", 1)[0] - slug_source = first_sentence if len(first_sentence) <= SLUG_SOURCE_LENGTH else text[ - :SLUG_SOURCE_LENGTH] + slug_source = ( + first_sentence + if len(first_sentence) <= SLUG_SOURCE_LENGTH + else first_sentence[:SLUG_SOURCE_LENGTH] + ) normalized = re.sub(r"[^a-z0-9]+", "-", slug_source.lower()).strip("-") if not normalized: normalized = "entry" @@ -43,7 +46,7 @@ def _encode_area(area: str) -> str: def _write_area_file(path: pathlib.Path, change: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(f"{change.rstrip(chr(10))}\n") + path.write_text(change.rstrip("\n") + "\n") def _area_metadata(areas: Iterable[str]) -> str: @@ -64,6 +67,11 @@ def migrate(project_root: pathlib.Path) -> None: current_entries_dir = changelogs_dir / "current" areas_yaml_path = changelogs_dir / "areas.yaml" + if not current_yaml_path.exists(): + raise FileNotFoundError(f"Missing required changelog file: {current_yaml_path}") + if not sections_yaml_path.exists(): + raise FileNotFoundError(f"Missing required changelog file: {sections_yaml_path}") + current_data = yaml.safe_load(current_yaml_path.read_text()) or {} sections_data = yaml.safe_load(sections_yaml_path.read_text()) or {} @@ -79,8 +87,13 @@ def migrate(project_root: pathlib.Path) -> None: if not section_entries: continue for entry in section_entries: - area = entry["area"] - change = entry["change"] + area = entry.get("area", "") + change = entry.get("change", "") + if not area: + raise ValueError(f"Entry in section '{section}' is missing a non-empty 'area' value") + if not change or not change.strip(): + raise ValueError( + f"Entry in section '{section}' and area '{area}' is missing a non-empty 'change' value") if area not in areas: areas.append(area) area_encoded = _encode_area(area) diff --git a/tools/changelogs/migrate_to_per_entry_test.py b/tools/changelogs/migrate_to_per_entry_test.py index 73a059a489156..c7456054a0084 100644 --- a/tools/changelogs/migrate_to_per_entry_test.py +++ b/tools/changelogs/migrate_to_per_entry_test.py @@ -15,7 +15,7 @@ class MigrateToPerEntryTest(unittest.TestCase): - def _write_repo_files(self, root: pathlib.Path, date_value: str = "Pending") -> None: + def _write_test_changelog_files(self, root: pathlib.Path, date_value: str = "Pending") -> None: changelogs_dir = root / "changelogs" changelogs_dir.mkdir(parents=True, exist_ok=True) (changelogs_dir / "sections.yaml").write_text(textwrap.dedent("""\ @@ -44,7 +44,7 @@ def _write_repo_files(self, root: pathlib.Path, date_value: str = "Pending") -> def test_migration_generates_entry_files_and_rewrites_yaml(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: root = pathlib.Path(tmpdir) - self._write_repo_files(root) + self._write_test_changelog_files(root) subprocess.run( [sys.executable, str(SCRIPT_PATH), "--root", str(root)], @@ -87,7 +87,7 @@ def test_migration_generates_entry_files_and_rewrites_yaml(self) -> None: def test_migration_preserves_non_pending_date(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: root = pathlib.Path(tmpdir) - self._write_repo_files(root, date_value="January 1, 2026") + self._write_test_changelog_files(root, date_value="January 1, 2026") subprocess.run( [sys.executable, str(SCRIPT_PATH), "--root", str(root)], From 2ff69c68ff159ef0a3f6aaa076ae6854922b919b Mon Sep 17 00:00:00 2001 From: phlax Date: Mon, 18 May 2026 19:12:48 +0100 Subject: [PATCH 4/4] Migrate to unified changelogs/changelogs.yaml config layout Post-migration toolshed expects a single `changelogs/changelogs.yaml` config file containing both `sections:` and `areas:` keys, rather than separate `sections.yaml` and `areas.yaml` files. Update the migration script to: - Read existing `changelogs/sections.yaml` and fold its content under `sections:` in the new `changelogs/changelogs.yaml`. - Place the discovered areas under `areas:` in the same file. - Remove the now-redundant `changelogs/sections.yaml` after writing. See: envoy.base.utils.abstract.project.changelog.CHANGELOG_CONFIG_PATH (= `changelogs/changelogs.yaml`) in envoyproxy/toolshed. --- tools/changelogs/migrate_to_per_entry.py | 44 +++++++++++++------ tools/changelogs/migrate_to_per_entry_test.py | 34 +++++++++----- 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/tools/changelogs/migrate_to_per_entry.py b/tools/changelogs/migrate_to_per_entry.py index a61f7a263cc50..7b6f1e8d6f6fe 100644 --- a/tools/changelogs/migrate_to_per_entry.py +++ b/tools/changelogs/migrate_to_per_entry.py @@ -12,6 +12,20 @@ SLUG_SOURCE_LENGTH = 60 ENTRY_SEPARATOR = "__" +CHANGELOGS_CONFIG_HEADER = ( + "# NB: this file is the canonical changelog config consumed by\n" + "# envoy.base.utils / envoy.code.check.\n" + "#\n" + "# `sections:` lists the valid top-level changelog sections (e.g.\n" + "# `bug_fixes`, `new_features`). Adding/removing a section requires\n" + "# coordinated updates in toolshed.\n" + "#\n" + "# `areas:` lists the canonical set of areas accepted by per-entry\n" + "# changelog filenames in changelogs/current/
/__.rst.\n" + "# In filenames, '/' MUST be encoded as '~'.\n" + "# Adding a new area requires a PR updating this file.\n" +) + def _truncate_slug(slug: str, max_length: int = MAX_SLUG_LENGTH) -> str: if len(slug) <= max_length: @@ -44,20 +58,21 @@ def _encode_area(area: str) -> str: return area.replace("/", "~") -def _write_area_file(path: pathlib.Path, change: str) -> None: +def _write_entry_file(path: pathlib.Path, change: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(change.rstrip("\n") + "\n") -def _area_metadata(areas: Iterable[str]) -> str: - header = ( - "# NB: areas listed here are the canonical set accepted by per-entry changelog\n" - "# filenames in changelogs/current/\n" - ) - area_dict = {area: {"title": area} for area in sorted(set(areas))} - if not area_dict: - return f"{header}\n" - return f"{header}\n{yaml.safe_dump(area_dict, sort_keys=False)}" +def _render_changelogs_config( + sections_data: dict, + areas: Iterable[str]) -> str: + config = { + "sections": sections_data or {}, + "areas": {area: {"title": area} for area in sorted(set(areas))}, + } + return ( + f"{CHANGELOGS_CONFIG_HEADER}\n" + f"{yaml.safe_dump(config, sort_keys=False, default_flow_style=False)}") def migrate(project_root: pathlib.Path) -> None: @@ -65,7 +80,7 @@ def migrate(project_root: pathlib.Path) -> None: current_yaml_path = changelogs_dir / "current.yaml" sections_yaml_path = changelogs_dir / "sections.yaml" current_entries_dir = changelogs_dir / "current" - areas_yaml_path = changelogs_dir / "areas.yaml" + changelogs_config_path = changelogs_dir / "changelogs.yaml" if not current_yaml_path.exists(): raise FileNotFoundError(f"Missing required changelog file: {current_yaml_path}") @@ -104,10 +119,13 @@ def migrate(project_root: pathlib.Path) -> None: if count > 1: slug = f"{slug}-{count}" entry_path = current_entries_dir / section / f"{area_encoded}{ENTRY_SEPARATOR}{slug}.rst" - _write_area_file(entry_path, change) + _write_entry_file(entry_path, change) current_yaml_path.write_text(yaml.safe_dump({"date": date}, sort_keys=False)) - areas_yaml_path.write_text(_area_metadata(areas)) + changelogs_config_path.write_text(_render_changelogs_config(sections_data, areas)) + # `sections.yaml` is superseded by `changelogs.yaml` (which now contains + # both `sections:` and `areas:`). + sections_yaml_path.unlink() def main() -> None: diff --git a/tools/changelogs/migrate_to_per_entry_test.py b/tools/changelogs/migrate_to_per_entry_test.py index c7456054a0084..868a03f4e1c6e 100644 --- a/tools/changelogs/migrate_to_per_entry_test.py +++ b/tools/changelogs/migrate_to_per_entry_test.py @@ -73,16 +73,30 @@ def test_migration_generates_entry_files_and_rewrites_yaml(self) -> None: "Add nested area support for oauth2 filter feature and behavior that goes beyond sixty characters for slug input.\n" "Keep second line as-is.\n") - expected_areas = textwrap.dedent("""\ - # NB: areas listed here are the canonical set accepted by per-entry changelog - # filenames in changelogs/current/ - - extensions/filters/http/oauth2: - title: extensions/filters/http/oauth2 - router: - title: router - """) - self.assertEqual((changelogs_dir / "areas.yaml").read_text(), expected_areas) + # The old `sections.yaml` is superseded by `changelogs.yaml`. + self.assertFalse((changelogs_dir / "sections.yaml").exists()) + self.assertFalse((changelogs_dir / "areas.yaml").exists()) + self.assertTrue((changelogs_dir / "changelogs.yaml").exists()) + + config = yaml.safe_load((changelogs_dir / "changelogs.yaml").read_text()) + self.assertEqual( + config, + { + "sections": { + "bug_fixes": {"title": "Bug fixes"}, + "new_features": {"title": "New features"}, + }, + "areas": { + "extensions/filters/http/oauth2": { + "title": "extensions/filters/http/oauth2", + }, + "router": {"title": "router"}, + }, + }) + # Header comments are preserved. + self.assertIn( + "# NB: this file is the canonical changelog config", + (changelogs_dir / "changelogs.yaml").read_text()) def test_migration_preserves_non_pending_date(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: