Skip to content
Merged
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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 21 additions & 7 deletions src/update_time/domain/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
64 changes: 46 additions & 18 deletions tests/update_time/domain/test_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)

Expand All @@ -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",
)
Expand All @@ -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",
)

Expand All @@ -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",
)

Expand Down
Loading