Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions tools/changelogs/migrate_to_per_entry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/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 = "__"

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/<section>/<area>__<slug>.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:
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 first_sentence[: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_entry_file(path: pathlib.Path, change: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(change.rstrip("\n") + "\n")


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:
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"
changelogs_config_path = changelogs_dir / "changelogs.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 {}

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.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)
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_entry_file(entry_path, change)

current_yaml_path.write_text(yaml.safe_dump({"date": date}, sort_keys=False))
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:
parser = argparse.ArgumentParser(
description="Migrate changelogs/current.yaml to changelogs/current/<section>/<area>__<slug>.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()
116 changes: 116 additions & 0 deletions tools/changelogs/migrate_to_per_entry_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/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_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("""\
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_test_changelog_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")

# 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:
root = pathlib.Path(tmpdir)
self._write_test_changelog_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()
Loading