Coverage for plugin/scripts/_rhiza_changelog.py: 100%

13 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +0000

1#!/usr/bin/env python3 

2r"""Read the newest version a `CHANGELOG.md` names — the one parser, for both readers. 

3 

4`/rhiza:release` asks this question twice, from two directions, and both answers have to 

5agree or the release strands: 

6 

7* `check_version_bump.py` reads the **local** file to decide which phase the repo is in. 

8 On a tag-derived repo it is the only evidence that a bump has landed, because there the 

9 declared version *is* the newest tag and so can never exceed it. 

10* `wait_for_merge.py` reads the file **on the remote default branch**, through 

11 `git show`, to decide whether the release PR's bump has arrived. 

12 

13Two copies of one regex is how those two come to disagree — one accepting `## [v1.7.0]` 

14and the other not, on a repo whose `cliff.toml` writes the prefix, would leave the wait 

15polling forever for a release the phase check can already see. So the pattern lives here 

16and neither caller owns it. 

17 

18What counts as a release heading is deliberately loose about everything except the 

19version: the level (`#` to `###`), the brackets, and a `v` prefix are all `cliff.toml` 

20decisions or hand-editing habits, and all of them appear in real changelogs. The digits 

21are the part that is not optional, which is what makes an `Unreleased` section invisible 

22here rather than something to special-case. 

23""" 

24 

25from __future__ import annotations 

26 

27import re 

28from pathlib import Path 

29 

30_CHANGELOG_HEADING = re.compile(r"^#{1,3}\s*\[?v?(\d+\.\d+\.\d+[0-9A-Za-z.+-]*)\]?", re.M) 

31 

32 

33def newest_changelog_version(text: str) -> str | None: 

34 r"""Return the newest version a changelog's headings name, or ``None``. 

35 

36 This is the committed evidence a tag-derived repo has and a manifest-declared one 

37 does not. `/rhiza:release` writes the section with `git-cliff --prepend`, so the 

38 newest release is the **first** version-shaped heading in the file. 

39 

40 >>> newest_changelog_version("# Changelog\n\n## [1.7.0] - 2026-09-07\n\n## [1.6.0]\n") 

41 '1.7.0' 

42 

43 An `Unreleased` heading is not version-shaped, so it is skipped rather than 

44 swallowing the section under it: 

45 

46 >>> newest_changelog_version("## [Unreleased]\n\n## [1.6.0] - 2026-09-04\n") 

47 '1.6.0' 

48 

49 A `v` prefix, a bare heading with no brackets, and a deeper level all parse: 

50 

51 >>> [ 

52 ... newest_changelog_version("## [v2.0.0]\n"), 

53 ... newest_changelog_version("## 0.9.1 - 2026-01-01\n"), 

54 ... newest_changelog_version("### [1.0.0-rc.1]\n"), 

55 ... ] 

56 ['2.0.0', '0.9.1', '1.0.0-rc.1'] 

57 

58 A file with no version heading at all yields nothing, rather than a guess: 

59 

60 >>> print(newest_changelog_version("# Changelog\n\nNothing released yet.\n")) 

61 None 

62 """ 

63 match = _CHANGELOG_HEADING.search(text) 

64 return match[1] if match else None 

65 

66 

67def read_changelog_version(path: Path) -> str | None: 

68 """Return the newest version named by the changelog at *path*, or ``None``. 

69 

70 A missing or unreadable file is not an error: a repo need not keep a changelog, and 

71 the phase decision in `check_version_bump.py` is what turns "no evidence" into a 

72 verdict. Reading it here keeps the markdown parsing in tested Python rather than in a 

73 caller's regex. 

74 """ 

75 try: 

76 text = path.read_text(encoding="utf-8", errors="replace") 

77 except OSError: 

78 return None 

79 return newest_changelog_version(text)