diff --git a/py/envoy.base.utils/envoy/base/utils/abstract/project/changelog.py b/py/envoy.base.utils/envoy/base/utils/abstract/project/changelog.py index 66811aed4f..be69f07a30 100644 --- a/py/envoy.base.utils/envoy/base/utils/abstract/project/changelog.py +++ b/py/envoy.base.utils/envoy/base/utils/abstract/project/changelog.py @@ -27,7 +27,10 @@ CHANGELOG_PATH_GLOB = "changelogs/*.*.*.yaml" CHANGELOG_PATH_FMT = "changelogs/{version}.yaml" CHANGELOG_CURRENT_PATH = "changelogs/current.yaml" +CHANGELOG_CURRENT_DIR_PATH = "changelogs/current" +CHANGELOG_ENTRY_GLOB = "*/*.rst" CHANGELOG_SECTIONS_PATH = "changelogs/sections.yaml" +ENTRY_SEPARATOR = "__" CHANGELOG_SUMMARY_PATH = "changelogs/summary.md" CHANGELOG_URL_TPL = ( "https://raw.githubusercontent.com/envoyproxy/envoy/" @@ -148,6 +151,32 @@ def get_data(cls, path) -> typing.ChangelogDict: in data.items() if v}) + @classmethod + def get_data_from_entries( + cls, + yaml_path: pathlib.Path, + entry_dir: pathlib.Path) -> "typing.ChangelogDict": + try: + yaml_data = utils.from_yaml(yaml_path, typing.ChangelogSourceDict) + except (_yaml.reader.ReaderError, utils.TypeCastingError) as e: + raise exceptions.ChangelogParseError( + f"Failed to parse: {yaml_path}\n{e}") + date = yaml_data["date"] + sections: dict[str, list[typing.ChangeDict]] = {} + for path in sorted(entry_dir.glob(CHANGELOG_ENTRY_GLOB)): + section = path.parent.name + if path.stem.count(ENTRY_SEPARATOR) != 1: + raise exceptions.ChangelogParseError( + f"Invalid entry filename " + f"(expected exactly one '{ENTRY_SEPARATOR}'): {path}") + area, _slug = path.stem.split(ENTRY_SEPARATOR, 1) + change = typing.Change(path.read_text()) + entry: typing.ChangeDict = dict(area=area, change=change) + sections.setdefault(section, []).append(entry) + return cast( + typing.ChangelogDict, + dict(date=date, **sections)) + def __init__( self, project, diff --git a/py/envoy.base.utils/tests/test_abstract_project_changelogs.py b/py/envoy.base.utils/tests/test_abstract_project_changelogs.py index 6a1d482016..67a2290127 100644 --- a/py/envoy.base.utils/tests/test_abstract_project_changelogs.py +++ b/py/envoy.base.utils/tests/test_abstract_project_changelogs.py @@ -1092,6 +1092,108 @@ def test_abstract_changelog_get_data(iters, patches, raises): == [(m_typing.ChangelogDict, expected), {}]) +def test_abstract_changelog_get_data_from_entries_happy_path(tmp_path): + yaml_path = tmp_path / "current.yaml" + yaml_path.write_text("date: Pending\n") + entry_dir = tmp_path / "entries" + (entry_dir / "bug_fixes").mkdir(parents=True) + (entry_dir / "new_features").mkdir(parents=True) + bug1 = entry_dir / "bug_fixes" / "oauth2__foo_fix.rst" + bug1.write_text("Fixed oauth2.\n") + bug2 = entry_dir / "bug_fixes" / "jwt__bar_fix.rst" + bug2.write_text("Fixed jwt.\n") + feat1 = entry_dir / "new_features" / "grpc__cool.rst" + feat1.write_text("New feature.\n") + + result = abstract.AChangelog.get_data_from_entries(yaml_path, entry_dir) + + assert result["date"] == "Pending" + assert set(result.keys()) == {"date", "bug_fixes", "new_features"} + assert len(result["bug_fixes"]) == 2 + assert len(result["new_features"]) == 1 + assert result["bug_fixes"][0]["area"] == "jwt" + assert result["bug_fixes"][1]["area"] == "oauth2" + assert result["new_features"][0]["area"] == "grpc" + + +def test_abstract_changelog_get_data_from_entries_arbitrary_section(tmp_path): + yaml_path = tmp_path / "current.yaml" + yaml_path.write_text("date: Pending\n") + entry_dir = tmp_path / "entries" + (entry_dir / "weird_section").mkdir(parents=True) + (entry_dir / "weird_section" / "foo__bar.rst").write_text("content\n") + + result = abstract.AChangelog.get_data_from_entries(yaml_path, entry_dir) + + assert "weird_section" in result + assert result["weird_section"][0]["area"] == "foo" + + +def test_abstract_changelog_get_data_from_entries_missing_separator(tmp_path): + yaml_path = tmp_path / "current.yaml" + yaml_path.write_text("date: Pending\n") + entry_dir = tmp_path / "entries" + (entry_dir / "bug_fixes").mkdir(parents=True) + (entry_dir / "bug_fixes" / "no_separator.rst").write_text("content\n") + + with pytest.raises(exceptions.ChangelogParseError) as exc_info: + abstract.AChangelog.get_data_from_entries(yaml_path, entry_dir) + + assert "no_separator.rst" in str(exc_info.value) + + +def test_abstract_changelog_get_data_from_entries_multiple_separators( + tmp_path): + yaml_path = tmp_path / "current.yaml" + yaml_path.write_text("date: Pending\n") + entry_dir = tmp_path / "entries" + (entry_dir / "bug_fixes").mkdir(parents=True) + (entry_dir / "bug_fixes" / "a__b__c.rst").write_text("content\n") + + with pytest.raises(exceptions.ChangelogParseError) as exc_info: + abstract.AChangelog.get_data_from_entries(yaml_path, entry_dir) + + assert "a__b__c.rst" in str(exc_info.value) + + +def test_abstract_changelog_get_data_from_entries_missing_yaml(tmp_path): + yaml_path = tmp_path / "nonexistent.yaml" + entry_dir = tmp_path / "entries" + entry_dir.mkdir() + + with pytest.raises(FileNotFoundError): + abstract.AChangelog.get_data_from_entries(yaml_path, entry_dir) + + +def test_abstract_changelog_get_data_from_entries_empty_dir(tmp_path): + yaml_path = tmp_path / "current.yaml" + yaml_path.write_text("date: Pending\n") + entry_dir = tmp_path / "entries" + entry_dir.mkdir() + + result = abstract.AChangelog.get_data_from_entries(yaml_path, entry_dir) + + assert result["date"] == "Pending" + assert list(result.keys()) == ["date"] + + +def test_abstract_changelog_get_data_from_entries_stable_ordering(tmp_path): + yaml_path = tmp_path / "current.yaml" + yaml_path.write_text("date: Pending\n") + entry_dir = tmp_path / "entries" + (entry_dir / "bug_fixes").mkdir(parents=True) + (entry_dir / "bug_fixes" / "z__last.rst").write_text("Last\n") + (entry_dir / "bug_fixes" / "a__first.rst").write_text("First\n") + (entry_dir / "bug_fixes" / "m__middle.rst").write_text("Middle\n") + + result1 = abstract.AChangelog.get_data_from_entries(yaml_path, entry_dir) + result2 = abstract.AChangelog.get_data_from_entries(yaml_path, entry_dir) + + assert result1 == result2 + areas = [e["area"] for e in result1["bug_fixes"]] + assert areas == ["a", "m", "z"] + + def test_abstract_changelog_constructor(): with pytest.raises(TypeError):