diff --git a/CHANGELOG.md b/CHANGELOG.md index 325a968..3d2c21a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] -No changes yet. +### Fixed + +- Show the full release notes of a version `X.Y.0` from a changelog that heads the release `X.Y` and names `X.Y.0` in its prose, instead of only its introduction. Closes [#407](https://github.com/ICTU/update-time/issues/407). ## 0.0.43 - 2026-10-11 diff --git a/src/update_time/domain/changelog.py b/src/update_time/domain/changelog.py index da822f6..c75f953 100644 --- a/src/update_time/domain/changelog.py +++ b/src/update_time/domain/changelog.py @@ -105,11 +105,10 @@ def _fenced_indexes(lines: list[str]) -> frozenset[int]: def _find_version_index(lines: list[str], version: str) -> int | None: - """Return the index of the line that introduces the version's changes, or None when absent. + """Return the index of the heading naming the version, or else of the first line naming it, or None when absent. A heading naming the version wins over any other line that names it, so that a prose mention inside another - version's section (for example "shipped in 0.10.0") doesn't anchor parsing in the wrong place. Where no heading - names the version, the first line that names it anchors parsing. + version's section (for example "shipped in 0.10.0") doesn't anchor parsing in the wrong place. """ fallback = None fenced = _fenced_indexes(lines) @@ -179,13 +178,28 @@ def _next_version_index(lines: list[str], start: int, version: str) -> int | Non return None +def _find_anchor(lines: list[str], version: str) -> tuple[str, int | None]: + """Return the version as the changelog names it, and the index of the line introducing its changes, if any. + + A changelog can head the changes of a `.0` version one component shorter, `1.11` for `1.11.0`. So a heading + naming the shorter version wins over prose naming the version itself. Where no line names the version itself, + any line naming the shorter version anchors parsing. + """ + start = _find_version_index(lines, version) + if start is not None and _heading_level(lines, start): + return version, start + if version.endswith(".0") and version.count(".") > 1: + short_version = version.removesuffix(".0") + short_start = _find_version_index(lines, short_version) + if short_start is not None and (start is None or _heading_level(lines, short_start)): + return short_version, short_start + return version, start + + def get_version_changes_from_changelog(text: str, version: str) -> str: """Return the changes for the version from the changelog, or nothing when the changelog does not name it.""" all_lines = text.splitlines() - start = _find_version_index(all_lines, version) - if start is None and version.endswith(".0") and version.count(".") > 1: - version = version.removesuffix(".0") - start = _find_version_index(all_lines, version) + version, start = _find_anchor(all_lines, version) if start is None: return "" end = _next_version_index(all_lines, start, version) diff --git a/tests/update_time/domain/test_changelog.py b/tests/update_time/domain/test_changelog.py index 4ad4e78..70fcecd 100644 --- a/tests/update_time/domain/test_changelog.py +++ b/tests/update_time/domain/test_changelog.py @@ -158,19 +158,16 @@ def test_longer_version_does_not_anchor_parsing(self): self.assertEqual(get_version_changes_from_changelog(text, version), v1_change) _NO_SHORTER_HEADING = Mutation( - changelog.get_version_changes_from_changelog, - ' if start is None and version.endswith(".0") and version.count(".") > 1:\n' - ' version = version.removesuffix(".0")\n' - " start = _find_version_index(all_lines, version)", - "", + changelog._find_anchor, + 'version.endswith(".0") and version.count(".") > 1:', + "False:", "a changelog heading a release one component shorter than the version reports no changes for it", ) _SHORTEN_ANY_VERSION = Mutation( - changelog.get_version_changes_from_changelog, - 'if start is None and version.endswith(".0") and version.count(".") > 1:\n' - ' version = version.removesuffix(".0")', - 'if start is None and version.count(".") > 1:\n version = version.rsplit(".", 1)[0]', + changelog._find_anchor, + 'version.endswith(".0") and version.count(".") > 1:\n short_version = version.removesuffix(".0")', + 'version.count(".") > 1:\n short_version = version.rsplit(".", 1)[0]', "a version that is no `.0` release reports the changes of the release one component shorter", ) @@ -183,9 +180,9 @@ def test_only_a_dot_zero_version_is_found_under_a_shorter_heading(self): self.assertEqual(get_version_changes_from_changelog(text, "1.11.2"), "") _SHORTER_VERSION_NOT_REBOUND = Mutation( - changelog.get_version_changes_from_changelog, - 'version = version.removesuffix(".0")\n start = _find_version_index(all_lines, version)', - 'start = _find_version_index(all_lines, version.removesuffix(".0"))', + changelog._find_anchor, + "return short_version, short_start", + "return version, short_start", "a changelog without heading markup naming a shorter version raises instead of reporting its changes", raises="ValueError: substring not found", ) @@ -198,9 +195,9 @@ def test_dot_zero_version_is_found_under_a_shorter_version_named_in_prose(self): self.assertEqual(get_version_changes_from_changelog(text, "1.11.0"), v1_change) _SHORTER_HEADING_TRIED_FIRST = Mutation( - changelog.get_version_changes_from_changelog, - "if start is None and", - 'if _find_version_index(all_lines, version.removesuffix(".0")) is not None and', + changelog._find_anchor, + "start is not None and _heading_level(lines, start):", + "False:", "a changelog heading both a version and the release one component shorter reports the shorter one's changes", ) @@ -211,10 +208,41 @@ def test_heading_naming_the_version_wins_over_a_shorter_heading(self): text = f"# Changelog\n\n## 1.11\n\nThe 1.11 series.\n\n{v1_change}\n\n## 1.10\n\n- Fixed ...\n" self.assertEqual(get_version_changes_from_changelog(text, "1.11.0"), v1_change) + _PROSE_WINS_OVER_A_SHORTER_HEADING = Mutation( + changelog._find_anchor, + " and _heading_level(lines, start):", + ":", + "a changelog heading a release one component shorter than the version, and naming the version in its prose, " + "reports the prose line alone", + ) + + @kills(_PROSE_WINS_OVER_A_SHORTER_HEADING) + def test_shorter_heading_anchors_parsing_rather_than_prose_naming_the_dot_zero_version(self): + """Test that a heading naming one component fewer anchors parsing rather than prose naming the version.""" + v1_change = "## Example 1.11\n\nWe released example 1.11.0.\n\n### New feature\n\n- Added ..." + text = f"# Changelog\n\n{v1_change}\n\n## Example 1.10\n\n- Fixed ...\n" + self.assertEqual(get_version_changes_from_changelog(text, "1.11.0"), v1_change) + + _SHORTER_PROSE_WINS = Mutation( + changelog._find_anchor, + " and (start is None or _heading_level(lines, short_start)):", + ":", + "a changelog without heading markup reports a `.0` version from wherever the version one component shorter " + "is first named", + ) + + @kills(_SHORTER_PROSE_WINS) + def test_dot_zero_version_line_anchors_parsing_rather_than_prose_naming_a_shorter_version(self): + """Test that without headings, a `.0` version's line anchors parsing, not a line naming a component fewer.""" + v1_change = "Version 1.11.0 released 2026-04-22\n\n- Fixed ...\n- Changed ..." + v2_change = "Version 1.12.0 released 2026-05-01\n\n- Dropped support for 1.11." + text = f"Changes\n\n{v2_change}\n\n{v1_change}\n\nVersion 1.10.0 released 2026-03-08\n\n- Fixed ...\n" + self.assertEqual(get_version_changes_from_changelog(text, "1.11.0"), v1_change) + _SHORTEN_TO_A_BARE_COMPONENT = Mutation( - changelog.get_version_changes_from_changelog, - 'if start is None and version.endswith(".0") and version.count(".") > 1:', - 'if start is None and version.endswith(".0"):', + changelog._find_anchor, + 'version.endswith(".0") and version.count(".") > 1:', + 'version.endswith(".0"):', "a changelog naming a two-component version nowhere reports the entry a stray digit sits in", )