# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Tests for `repomatic.changelog`: parsing and updating the changelog."""
from __future__ import annotations
import logging
from dataclasses import asdict
from textwrap import dedent
import pytest
from repomatic.changelog import (
Changelog,
VersionElements,
availability_admonition,
)
from repomatic.templating import render_template
from repomatic.tooling.runner import verify_via_write_path
from tests.conftest import MULTI_RELEASE_CHANGELOG, skip_unless_tool_runs
SAMPLE_CHANGELOG = dedent(
"""\
# Changelog
## [`1.2.3` (unreleased)](https://github.com/user/repo/compare/v1.2.2...main)
> [!WARNING]
> This version is **not released yet** and is under active development.
- Add new feature.
- Fix bug.
## [`1.2.2` (2024-01-15)](https://github.com/user/repo/compare/v1.2.1...v1.2.2)
- Previous release.
"""
)
"""Reusable changelog fixture for freeze tests."""
[docs]
@pytest.mark.parametrize(
("version", "initial", "modified", "updated"),
[
("1.1.1", None, False, "# Changelog"),
("1.1.1", "", False, "# Changelog"),
(
"1.2.1",
dedent(
"""\
# Changelog
## [1.2.1 (unreleased)](https://github.com/kdeldycke/extra-platforms/compare/v1.2.0...main)
> [!WARNING]
> This version is **not released yet** and is under active development.
- Fix changelog indention.
"""
),
False,
dedent(
"""\
# Changelog
## [1.2.1 (unreleased)](https://github.com/kdeldycke/extra-platforms/compare/v1.2.0...main)
> [!WARNING]
> This version is **not released yet** and is under active development.
- Fix changelog indention."""
),
),
(
"1.0.0",
dedent(
"""\
# Changelog
## [1.0.0 (2024-08-20)](https://github.com/kdeldycke/extra-platforms/compare/v0.0.1...v1.0.0)
- Add documentation.
"""
),
True,
dedent(
"""\
# Changelog
## [`1.0.0` (unreleased)](https://github.com/kdeldycke/extra-platforms/compare/v1.0.0...main)
> [!WARNING]
> This version is **not released yet** and is under active development.
## [1.0.0 (2024-08-20)](https://github.com/kdeldycke/extra-platforms/compare/v0.0.1...v1.0.0)
- Add documentation."""
),
),
],
)
def test_changelog_update(version, initial, modified, updated):
"""The update reports whether it added an entry.
An unchanged changelog compares against it: the CLI exits early on `False`.
"""
changelog = Changelog(initial, current_version=version)
assert changelog.update() is modified
assert changelog.content.rstrip() == updated
if not modified:
assert changelog.content == (initial or "# Changelog\n")
[docs]
def test_freeze():
"""Test that freeze applies all three operations."""
changelog = Changelog(SAMPLE_CHANGELOG, current_version="1.2.3")
result = changelog.freeze(release_date="2026-02-14")
assert result is True
assert "(unreleased)" not in changelog.content
assert "(2026-02-14)" in changelog.content
assert "...main)" not in changelog.content
assert "...v1.2.3)" in changelog.content
assert "[!WARNING]" not in changelog.content
# Release notes are preserved.
assert "- Add new feature." in changelog.content
assert "- Fix bug." in changelog.content
[docs]
def test_freeze_idempotent():
"""Test that freezing an already-frozen changelog is a no-op."""
changelog = Changelog(SAMPLE_CHANGELOG, current_version="1.2.3")
changelog.freeze(release_date="2026-02-14")
frozen_content = changelog.content
# Second freeze should not change anything.
result = changelog.freeze(release_date="2026-02-14")
assert result is False
assert changelog.content == frozen_content
[docs]
def test_freeze_file(tmp_path):
"""Test that freeze_file freezes a changelog on disk."""
path = tmp_path / "changelog.md"
path.write_text(SAMPLE_CHANGELOG, encoding="UTF-8")
result = Changelog.freeze_file(path, version="1.2.3", release_date="2026-02-14")
assert result is True
content = path.read_text(encoding="UTF-8")
assert "(unreleased)" not in content
assert "(2026-02-14)" in content
assert "...main)" not in content
assert "...v1.2.3)" in content
assert "[!WARNING]" not in content
assert "- Add new feature." in content
[docs]
def test_freeze_file_already_released(tmp_path):
"""Test that freeze_file is a no-op for released changelogs."""
path = tmp_path / "changelog.md"
content = "# Changelog\n\n## [1.0.0 (2024-01-01)](https://example.com)\n"
path.write_text(content, encoding="UTF-8")
result = Changelog.freeze_file(path, version="1.0.0", release_date="2026-02-14")
assert result is False
assert path.read_text(encoding="UTF-8") == content
[docs]
def test_freeze_file_missing(tmp_path):
"""Test that freeze_file handles missing files gracefully."""
path = tmp_path / "nonexistent.md"
result = Changelog.freeze_file(path, version="1.0.0", release_date="2026-02-14")
assert result is False
# ---------------------------------------------------------------------------
# decompose_version / replace_section tests
# ---------------------------------------------------------------------------
DECOMPOSE_CHANGELOG = dedent(
"""\
# Changelog
## [`3.0.0` (unreleased)](https://github.com/user/repo/compare/v2.0.0...main)
> [!WARNING]
> This version is **not released yet** and is under active development.
- New feature.
## [`2.0.0` (2026-02-01)](https://github.com/user/repo/compare/v1.0.0...v2.0.0)
> [!NOTE]
> `2.0.0` is available on [🐍 PyPI](https://pypi.org/project/pkg/2.0.0/).
> [!CAUTION]
> `2.0.0` has been [yanked from PyPI](https://pypi.org/project/pkg/2.0.0/).
- Breaking change.
- New API.
## [`1.0.0` (2025-12-01)](https://github.com/user/repo/compare/v0.9.0...v1.0.0)
- Initial release.
"""
)
"""Changelog with all element types for decompose tests."""
[docs]
@pytest.mark.parametrize(
("version", "expected_date", "expected_url", "contains", "equals"),
[
pytest.param(
"3.0.0",
"unreleased",
"https://github.com/user/repo/compare/v2.0.0...main",
{"development_warning": ("contains", "not released yet")},
{"changes": "- New feature.", "editorial_admonition": ""},
id="dev-warning-and-changes",
),
pytest.param(
"2.0.0",
"2026-02-01",
"https://github.com/user/repo/compare/v1.0.0...v2.0.0",
{
"availability_admonition": (
"contains",
["[!NOTE]", "is available on"],
),
"yanked_admonition": (
"contains",
["[!CAUTION]", "yanked from PyPI"],
),
"changes": (
"contains",
["- Breaking change.", "- New API."],
),
},
{"development_warning": "", "editorial_admonition": ""},
id="availability-and-yanked",
),
pytest.param(
"1.0.0",
"2025-12-01",
"https://github.com/user/repo/compare/v0.9.0...v1.0.0",
{},
{
"development_warning": "",
"availability_admonition": "",
"editorial_admonition": "",
"yanked_admonition": "",
"changes": "- Initial release.",
},
id="changes-only",
),
],
)
def test_decompose_version(version, expected_date, expected_url, contains, equals):
"""Decompose versions with varying element combinations."""
changelog = Changelog(DECOMPOSE_CHANGELOG)
elements = changelog.decompose_version(version)
assert elements.version == version
assert elements.date == expected_date
assert elements.compare_url == expected_url
for field, (_, targets) in contains.items():
value = getattr(elements, field)
for target in targets if isinstance(targets, list) else [targets]:
assert target in value
for field, expected in equals.items():
assert getattr(elements, field) == expected
[docs]
def test_decompose_version_multiple_editorial_admonitions():
"""Multiple editorial admonitions are joined in editorial_admonition."""
changelog_text = dedent(
"""\
# Changelog
## [`1.0.0` (2025-12-01)](https://github.com/u/r/compare/v0.9.0...v1.0.0)
> [!NOTE]
> First editorial note.
> [!CAUTION]
> Important editorial caution.
- Some change.
"""
)
changelog = Changelog(changelog_text)
elements = changelog.decompose_version("1.0.0")
assert "[!NOTE]" in elements.editorial_admonition
assert "First editorial note." in elements.editorial_admonition
assert "[!CAUTION]" in elements.editorial_admonition
assert "Important editorial caution." in elements.editorial_admonition
assert "\n\n" in elements.editorial_admonition
assert "- Some change." in elements.changes
assert "[!NOTE]" not in elements.changes
assert "[!CAUTION]" not in elements.changes
[docs]
def test_decompose_version_editorial_with_auto_generated():
"""Editorial and auto-generated admonitions are correctly separated."""
changelog_text = dedent(
"""\
# Changelog
## [`2.0.0` (2026-01-15)](https://github.com/u/r/compare/v1.0.0...v2.0.0)
> [!NOTE]
> `2.0.0` is available on [🐍 PyPI](https://pypi.org/project/pkg/2.0.0/).
> [!NOTE]
> This is a hand-written editorial note.
- Breaking change.
"""
)
changelog = Changelog(changelog_text)
elements = changelog.decompose_version("2.0.0")
assert "is available on" in elements.availability_admonition
assert "hand-written editorial note" in elements.editorial_admonition
assert "hand-written editorial note" not in elements.availability_admonition
assert "is available on" not in elements.editorial_admonition
assert "- Breaking change." in elements.changes
[docs]
def test_decompose_version_empty():
"""Version not found returns empty elements."""
changelog = Changelog(DECOMPOSE_CHANGELOG)
elements = changelog.decompose_version("9.9.9")
assert elements == VersionElements()
[docs]
@pytest.mark.parametrize(
("version", "new_section", "expected", "present", "absent"),
[
pytest.param(
"1.0.0",
(
"## [`1.0.0` (2025-12-15)]"
"(https://github.com/user/repo/compare/v0.9.0...v1.0.0)\n\n"
"- Updated release.\n"
),
True,
["- Updated release.", "2025-12-15", "- Breaking change.", "3.0.0"],
["- Initial release."],
id="replaces-section",
),
pytest.param(
"9.9.9",
"## [`9.9.9` ...]\n\n- x\n",
False,
["- Initial release."],
[],
id="no-match",
),
],
)
def test_replace_section(version, new_section, expected, present, absent):
"""Test replacing an entire section (heading + body)."""
changelog = Changelog(DECOMPOSE_CHANGELOG)
changed = changelog.replace_section(version, new_section)
assert changed is expected
for text in present:
assert text in changelog.content
for text in absent:
assert text not in changelog.content
[docs]
def test_insert_version_section():
"""Test inserting a placeholder section for a missing version."""
changelog = Changelog(MULTI_RELEASE_CHANGELOG)
all_versions = ["2.0.0", "1.1.0", "1.0.5", "1.0.0"]
result = changelog.insert_version_section(
"1.0.5", "2026-01-15", "https://github.com/user/repo", all_versions
)
assert result is True
assert "## [`1.0.5` (2026-01-15)]" in changelog.content
assert "compare/v1.0.0...v1.0.5" in changelog.content
# The next-higher version (1.1.0) should now point to 1.0.5.
assert "compare/v1.0.5...v1.1.0" in changelog.content
[docs]
def test_insert_version_section_idempotent():
"""Test that inserting an already-present version is a no-op."""
changelog = Changelog(MULTI_RELEASE_CHANGELOG)
all_versions = ["2.0.0", "1.1.0", "1.0.0"]
result = changelog.insert_version_section(
"1.1.0", "2026-02-10", "https://github.com/user/repo", all_versions
)
assert result is False
[docs]
def test_insert_version_section_at_end():
"""Test inserting a version older than all existing ones."""
changelog = Changelog(MULTI_RELEASE_CHANGELOG)
all_versions = ["2.0.0", "1.1.0", "1.0.0", "0.5.0"]
result = changelog.insert_version_section(
"0.5.0", "2025-06-01", "https://github.com/user/repo", all_versions
)
assert result is True
assert "## [`0.5.0` (2025-06-01)]" in changelog.content
# Should be at the end, with comparison base v0.0.0 (no lower version).
assert "compare/v0.0.0...v0.5.0" in changelog.content
# 1.0.0 should now point to 0.5.0.
assert "compare/v0.5.0...v1.0.0" in changelog.content
[docs]
@pytest.mark.parametrize(
("version", "new_base", "expected", "present", "absent"),
[
pytest.param(
"1.1.0",
"1.0.5",
True,
"compare/v1.0.5...v1.1.0",
"compare/v1.0.0...v1.1.0",
id="replaces-base",
),
pytest.param(
"9.9.9",
"1.0.0",
False,
"compare/v1.0.0...v1.1.0",
None,
id="no-match",
),
],
)
def test_update_comparison_base(version, new_base, expected, present, absent):
"""Test replacing the comparison base in a version heading."""
changelog = Changelog(MULTI_RELEASE_CHANGELOG)
result = changelog.update_comparison_base(version, new_base)
assert result is expected
assert present in changelog.content
if absent:
assert absent not in changelog.content
DEV_ONLY_CHANGELOG = dedent(
"""\
# Changelog
## [`1.0.0.dev0` (unreleased)](https://github.com/user/repo/compare/v0.9.0...main)
> [!WARNING]
> This version is **not released yet** and is under active development.
"""
)
"""Changelog mid-development cycle, carrying no final-release heading."""
FROZEN_CHANGELOG = dedent(
"""\
# Changelog
## [`1.2.3` (2026-03-01)](https://github.com/user/repo/compare/v1.2.2...v1.2.3)
- Add papaya sorting.
"""
)
"""Changelog whose newest section is already frozen, ready for a post-release bump."""
[docs]
def test_insert_version_section_orders_against_dev_headings():
"""A `.devN` heading takes part in the insertion-point scan.
The published `1.0.0` outranks the `1.0.0.dev0` section still sitting in the
changelog, so it belongs above it. A scan blind to development headings
finds no lower version, falls through to the append-at-end branch, and files
the newest release at the bottom.
"""
changelog = Changelog(DEV_ONLY_CHANGELOG)
result = changelog.insert_version_section(
"1.0.0", "2026-03-01", "https://github.com/user/repo", ["1.0.0.dev0", "1.0.0"]
)
assert result is True
released_at = changelog.content.index("## [`1.0.0` (2026-03-01)]")
development_at = changelog.content.index("## [`1.0.0.dev0` (unreleased)]")
assert released_at < development_at
[docs]
@pytest.mark.parametrize(
("kwargs", "expected"),
[
pytest.param({}, "compare/v1.2.3...main", id="default-branch"),
pytest.param(
{"default_branch": "trunk"}, "compare/v1.2.3...trunk", id="custom-branch"
),
],
)
def test_update_retargets_comparison_url_to_default_branch(kwargs, expected):
"""The new unreleased entry points at the branch the caller names.
`freeze` retargets this same URL back to a tag, so a hard-coded `main` here
leaves any repository on another default branch unable to round-trip.
"""
changelog = Changelog(FROZEN_CHANGELOG, current_version="1.2.3")
assert changelog.update(**kwargs) is True
assert expected in changelog.content
[docs]
def test_freeze_warns_when_section_is_missing(caplog):
"""A version with no changelog section warns instead of quietly no-opping."""
changelog = Changelog(MULTI_RELEASE_CHANGELOG, current_version="9.9.9")
with caplog.at_level(logging.WARNING):
assert changelog.freeze(release_date="2026-03-01") is False
assert "No changelog section found for version 9.9.9" in caplog.text
[docs]
def test_freeze_already_frozen_stays_silent(caplog):
"""The idempotent no-op path must not warn, or every re-run cries wolf."""
changelog = Changelog(MULTI_RELEASE_CHANGELOG, current_version="1.1.0")
with caplog.at_level(logging.WARNING):
assert changelog.freeze(release_date="2026-03-01") is False
assert caplog.records == []
[docs]
def test_decomposition_follows_every_content_change():
"""The cached sections are dropped whenever the content changes."""
changelog = Changelog(MULTI_RELEASE_CHANGELOG)
assert changelog.decompose_version("1.1.0").changes == "- Second release."
elements = changelog.decompose_version("1.1.0")
elements.changes = "- Second release, with mangoes."
assert changelog.replace_section(
"1.1.0", render_template("release-notes", **asdict(elements))
)
assert changelog.decompose_version("1.1.0").changes == (
"- Second release, with mangoes."
)
changelog.insert_version_section(
"1.0.5", "2026-01-15", "https://github.com/user/repo", ["1.1.0", "1.0.0"]
)
assert changelog.decompose_version("1.0.5").date == "2026-01-15"
assert changelog.decompose_version("1.1.0").compare_url.endswith("v1.0.5...v1.1.0")
[docs]
def test_decomposition_hands_out_copies():
"""Changing a returned decomposition leaves the next one untouched."""
changelog = Changelog(MULTI_RELEASE_CHANGELOG)
changelog.decompose_version("1.0.0").changes = "- Mangled."
assert changelog.decompose_version("1.0.0").changes == "- Initial release."
[docs]
def test_insert_version_section_ignores_unparsable_versions():
"""A version PEP 440 refuses takes no part in the ordering."""
content = MULTI_RELEASE_CHANGELOG.replace(
"## [1.0.0 (2025-12-01)]", "## [1.0.0.papaya (2025-12-01)]"
)
changelog = Changelog(content)
assert changelog.insert_version_section(
"1.0.5",
"2026-01-15",
"https://github.com/user/repo",
["1.1.0", "1.0.0.papaya", "1.0.0"],
)
assert "compare/v1.0.0...v1.0.5" in changelog.content
assert "compare/v1.0.5...v1.1.0" in changelog.content
[docs]
@pytest.mark.parametrize(
("elements", "has_body", "tag_range"),
(
pytest.param(VersionElements(), False, "", id="empty"),
pytest.param(
VersionElements(
compare_url="https://github.com/user/repo/compare/v1.0.0...v1.1.0",
changes="- Add kiwis.",
),
True,
"v1.0.0...v1.1.0",
id="released",
),
pytest.param(
VersionElements(development_warning="> [!WARNING]\n> Unreleased."),
True,
"",
id="warning-only",
),
),
)
def test_version_elements_queries(elements, has_body, tag_range):
assert elements.has_body is has_body
assert elements.tag_range == tag_range
[docs]
@pytest.mark.parametrize(
("changes", "expected"),
(
("", []),
("- Only one entry.", ["- Only one entry."]),
("- One.\n- Two.\n- Three.", ["- One.", "- Two.", "- Three."]),
(
"- Wrapped entry that\n continues on the next line.",
["- Wrapped entry that\n continues on the next line."],
),
(
"- Parent entry.\n - Child A.\n - Child B.",
["- Parent entry.\n - Child A.\n - Child B."],
),
(
"Intro prose, not a bullet.\n\n- The only real bullet.",
["- The only real bullet."],
),
),
)
def test_version_elements_bullets(changes, expected):
"""Each top-level `-` starts one entry; wraps and sub-bullets fold into it."""
assert VersionElements(changes=changes).bullets == expected
[docs]
def test_tag_prefixed_headings_are_found_and_stripped():
"""A heading written with the `v` of a tag is invisible until stripped."""
changelog = Changelog(
"# Changelog\n\n"
"## [`v1.2.3` (2026-02-14)]"
"(https://github.com/user/repo/compare/v1.2.2...v1.2.3)\n\n"
"- Add a feature.\n\n"
"## [v1.2.2 (2026-01-01)]"
"(https://github.com/user/repo/compare/v1.2.1...v1.2.2)\n"
)
assert changelog.extract_all_releases() == []
assert changelog.tag_prefixed_versions() == ["1.2.3", "1.2.2"]
assert changelog.strip_tag_prefixes() == 2
assert changelog.tag_prefixed_versions() == []
assert changelog.extract_all_releases() == [
("1.2.3", "2026-02-14"),
("1.2.2", "2026-01-01"),
]
assert "compare/v1.2.2...v1.2.3" in changelog.content