Source code for tests.test_release_sync

# 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.github.release_sync`: changelog-to-release-notes sync."""

from __future__ import annotations

from unittest.mock import Mock, patch

import pytest
from click_extra.testing import CliRunner

from repomatic.changelog import Changelog, availability_admonition
from repomatic.cli.main import repomatic
from repomatic.github.gh import GhCommandError
from repomatic.github.release_sync import (
    SyncResult,
    SyncRow,
    _normalize_body,
    build_draft_body,
    build_expected_body,
    build_pypi_body,
    render_sync_report,
    sync_github_releases,
)
from repomatic.github.releases import GitHubRelease, GitHubReleasesUnavailable
from repomatic.tabular import ReportAction

SAMPLE_CHANGELOG = """\
# Changelog

## [`2.0.0` (2026-01-15)](https://github.com/user/repo/compare/v1.1.0...v2.0.0)

- Breaking change: redesigned API.
- Added new feature X.

## [`1.1.0` (2026-01-10)](https://github.com/user/repo/compare/v1.0.0...v1.1.0)

> [!NOTE]
> `1.1.0` is available on PyPI.

- Fixed bug in parser.
- Improved performance.

## [`1.0.0` (2026-01-01)](https://github.com/user/repo/compare/v0.9.0...v1.0.0)

- Initial release.
"""


[docs] def test_build_expected_body(): """Verbatim extraction from changelog.""" changelog = Changelog(SAMPLE_CHANGELOG) body = build_expected_body(changelog, "2.0.0") assert "Breaking change: redesigned API." in body assert "Added new feature X." in body
[docs] def test_build_expected_body_with_admonition(): """Extraction preserves admonitions when they appear in the changes group.""" changelog_with_admonition = """\ # Changelog ## [`3.0.0` (2026-02-01)](https://github.com/user/repo/compare/v2.0.0...v3.0.0) > [!NOTE] > First release under the new name on PyPI. - New feature. """ changelog = Changelog(changelog_with_admonition) body = build_expected_body(changelog, "3.0.0") assert "> [!NOTE]" in body assert "New feature." in body
[docs] def test_build_expected_body_admonition_override(): """Override replaces the changelog's availability admonition.""" changelog = Changelog(SAMPLE_CHANGELOG) override = ( "> [!NOTE]\n" "> `1.1.0` is available on" " [🐍 PyPI](https://pypi.org/project/pkg/1.1.0/)" " and [🐙 GitHub release](https://github.com/user/repo/releases/tag/v1.1.0)." ) body = build_expected_body(changelog, "1.1.0", admonition_override=override) # The override admonition should appear in the rendered body. assert "🐍 PyPI" in body assert "🐙 GitHub release" in body # Original changes should still be present. assert "Fixed bug in parser." in body
[docs] def test_build_expected_body_admonition_override_no_original(): """Override injects an admonition where none existed in the changelog.""" changelog = Changelog(SAMPLE_CHANGELOG) override = ( "> [!NOTE]\n" "> `2.0.0` is available on" " [🐍 PyPI](https://pypi.org/project/pkg/2.0.0/)." ) body = build_expected_body(changelog, "2.0.0", admonition_override=override) assert "🐍 PyPI" in body assert "Breaking change: redesigned API." in body
[docs] def test_build_expected_body_editorial_only(): """Version with only editorial admonition returns non-empty body.""" changelog_text = """\ # Changelog ## [`4.0.0` (2026-03-01)](https://github.com/user/repo/compare/v3.0.0...v4.0.0) > [!NOTE] > This is a hand-written editorial note. """ changelog = Changelog(changelog_text) body = build_expected_body(changelog, "4.0.0") assert body != "" assert "hand-written editorial note" in body
[docs] def test_build_expected_body_missing_version(): """Returns empty string for a missing version.""" changelog = Changelog(SAMPLE_CHANGELOG) assert build_expected_body(changelog, "9.9.9") == ""
[docs] def test_build_expected_body_includes_all_elements(): """The github-releases template includes all elements like release-notes.""" changelog_with_admonitions = """\ # Changelog ## [`1.0.0` (2025-12-01)](https://github.com/user/repo/compare/v0.9.0...v1.0.0) > [!NOTE] > `1.0.0` is available on [🐍 PyPI](https://pypi.org/project/pkg/1.0.0/). - Initial release. """ changelog = Changelog(changelog_with_admonitions) body = build_expected_body(changelog, "1.0.0") assert "- Initial release." in body assert "[!NOTE]" in body assert "is available on" in body
[docs] def test_build_expected_body_decomposes_elements(): """Verifies the decompose → template path produces correct output.""" changelog = Changelog(SAMPLE_CHANGELOG) body = build_expected_body(changelog, "1.1.0") # Changes should be present. assert "Fixed bug in parser." in body assert "Improved performance." in body # The availability admonition is included by the github-releases template. assert "`1.1.0` is available on" in body
[docs] def test_build_draft_body_bakes_the_pypi_admonition(): """A PyPI project's draft carries its availability admonition from the start. The draft is created before `fix-changelog` writes the admonition into the changelog, which is the race the bake closes. """ changelog = Changelog(SAMPLE_CHANGELOG) body = build_draft_body(changelog, "2.0.0", "papaya") assert body == build_pypi_body(changelog, "2.0.0", "papaya") assert "https://pypi.org/project/papaya/2.0.0/" in body assert "https://github.com/user/repo/releases/tag/v2.0.0" in body assert "- Added new feature X." in body
[docs] def test_build_draft_body_falls_back_to_the_plain_notes(): """A project not on PyPI gets the changelog section as it is.""" changelog = Changelog(SAMPLE_CHANGELOG) body = build_draft_body(changelog, "2.0.0", None) assert body == build_expected_body(changelog, "2.0.0") assert "pypi.org" not in body
RELEASE_SECTIONS = { "1.1.0": ( "## [`1.1.0` (2026-02-10)](https://github.com/user/repo/compare/v1.0.0...v1.1.0)" "\n\n- Second release.\n" ), "1.0.0": ( "## [`1.0.0` (2026-01-15)](https://github.com/user/repo/compare/v0.9.0...v1.0.0)" "\n\n- Initial release.\n" ), } """Released changelog sections, newest first, keyed by their version."""
[docs] @pytest.mark.parametrize( ("released", "first"), ( pytest.param(("1.0.0",), True, id="first-release"), pytest.param(("1.1.0", "1.0.0"), False, id="later-release"), ), ) def test_draft_release_notes_word_availability_like_the_changelog(released, first): """The draft release body states availability the way the changelog will. `lint-changelog --fix` writes the *first version* wording into the section of a project's first release. The draft body is baked before that fix runs, so it has to reach the same wording on its own, or the two disagree. """ version = released[0] content = "# Changelog\n\n" + "\n".join(RELEASE_SECTIONS[v] for v in released) notes = build_draft_body(Changelog(content), version, "papaya") expected = availability_admonition( version, pypi_url=f"https://pypi.org/project/papaya/{version}/", github_url=f"https://github.com/user/repo/releases/tag/v{version}", first_on_pypi=first, first_on_github=first, ) assert expected in notes assert ("is the *first version* available on" in notes) is first
[docs] def test_build_pypi_body_needs_a_repository(): """A changelog naming no repository cannot link the GitHub release.""" changelog = Changelog("# Changelog\n\n## [`1.0.0` (2026-01-01)]\n\n- Seed.\n") assert build_pypi_body(changelog, "1.0.0", "papaya") == "" assert build_draft_body(changelog, "1.0.0", "papaya") == build_expected_body( changelog, "1.0.0" )
[docs] @pytest.mark.parametrize( ("text", "expected"), [ ("hello \n world \n", "hello\n world"), ("\n\nfoo\n\n", "foo"), ("no trailing", "no trailing"), ("", ""), (" \n \n ", ""), ], ) def test_normalize_body(text, expected): """Whitespace normalization edge cases.""" assert _normalize_body(text) == expected
[docs] @pytest.fixture def changelog_path(tmp_path): """A changelog file on disk holding `SAMPLE_CHANGELOG`.""" path = tmp_path / "changelog.md" path.write_text(SAMPLE_CHANGELOG, encoding="UTF-8") return path
[docs] def expected_body(version: str) -> str: """The release body `sync_github_releases` would render for *version*.""" return build_expected_body(Changelog(SAMPLE_CHANGELOG), version)
[docs] def run_sync(changelog_path, releases, *, dry_run=True, edit_result=None): """Drive `sync_github_releases` against canned GitHub state. :param changelog_path: The changelog the expected bodies are derived from. :param releases: Version to `GitHubRelease`, as the API would return them. :param dry_run: Passed straight through to the command. :param edit_result: When set, `edit_release_notes` is patched to return it and the mock is handed back beside the result so a caller can assert on the calls. Left `None`, no mutation seam is installed. :return: The sync result, or `(result, edit_mock)` when *edit_result* is set. """ releases_patch = patch( "repomatic.github.release_sync.get_github_releases", return_value=releases ) if edit_result is None: with releases_patch: return sync_github_releases( "https://github.com/user/repo", changelog_path, dry_run=dry_run ) with ( releases_patch, patch( "repomatic.github.release_sync.edit_release_notes", return_value=edit_result, ) as edit_mock, ): result = sync_github_releases( "https://github.com/user/repo", changelog_path, dry_run=dry_run ) return result, edit_mock
[docs] def test_sync_all_in_sync(changelog_path): """No updates when all bodies match.""" result = run_sync( changelog_path, { "2.0.0": GitHubRelease(date="2026-01-15", body=expected_body("2.0.0")), "1.1.0": GitHubRelease(date="2026-01-10", body=expected_body("1.1.0")), "1.0.0": GitHubRelease(date="2026-01-01", body=expected_body("1.0.0")), }, ) assert result.total == 3 assert result.in_sync == 3 assert result.drifted == 0 assert result.updated == 0 assert result.failed == 0
[docs] def test_sync_detects_drift(changelog_path): """Flags releases with different bodies in dry-run.""" result = run_sync( changelog_path, { "2.0.0": GitHubRelease(date="2026-01-15", body="old body"), "1.1.0": GitHubRelease(date="2026-01-10", body=expected_body("1.1.0")), "1.0.0": GitHubRelease(date="2026-01-01", body="stale content"), }, ) assert result.total == 3 assert result.in_sync == 1 assert result.drifted == 2 # Dry-run rows should use DRY_RUN action. drifted_rows = [r for r in result.rows if r.action == ReportAction.DRY_RUN] assert len(drifted_rows) == 2
[docs] def test_sync_dry_run_no_mutations(changelog_path): """Dry-run doesn't call ``gh release edit``.""" _result, edit_mock = run_sync( changelog_path, {"2.0.0": GitHubRelease(date="2026-01-15", body="old body")}, edit_result=True, ) edit_mock.assert_not_called()
[docs] def test_sync_live_updates(changelog_path): """Live mode calls ``gh release edit`` with correct args.""" result, edit_mock = run_sync( changelog_path, {"2.0.0": GitHubRelease(date="2026-01-15", body="old body")}, dry_run=False, edit_result=True, ) edit_mock.assert_called_once_with("v2.0.0", "user/repo", expected_body("2.0.0")) assert result.updated == 1 assert result.drifted == 1
[docs] def test_sync_missing_changelog_section(changelog_path): """A GitHub release with no changelog section is never iterated.""" result = run_sync( changelog_path, { "2.0.0": GitHubRelease(date="2026-01-15", body="body"), "1.1.0": GitHubRelease(date="2026-01-10", body="body"), "1.0.0": GitHubRelease(date="2026-01-01", body="body"), # Released on GitHub, absent from the changelog. "3.0.0": GitHubRelease(date="2026-02-01", body="something"), }, ) assert result.total == 3
[docs] def test_sync_counts_an_empty_changelog_section(tmp_path): """A released version whose section holds nothing is counted, not compared.""" path = tmp_path / "changelog.md" section = "## [`0.9.0` (2025-12-01)](https://github.com/user/repo)\n" path.write_text(f"{SAMPLE_CHANGELOG}\n{section}", encoding="UTF-8") result = run_sync( path, { "1.0.0": GitHubRelease(date="2026-01-01", body=expected_body("1.0.0")), "0.9.0": GitHubRelease(date="2025-12-01", body="Seedlings."), }, ) assert result.total == 4 assert result.in_sync == 1 assert result.missing_changelog == 1 assert [row.version for row in result.rows] == ["1.0.0"] assert "| ❓ Missing changelog | 1 |" in render_sync_report(result)
[docs] @pytest.mark.parametrize( ("repo_url", "answer"), ( pytest.param("https://gitlab.com/user/repo", {}, id="not-on-github"), pytest.param( "https://github.com/user/repo", GitHubReleasesUnavailable("HTTP 502: Bad Gateway"), id="api-unavailable", ), pytest.param("https://github.com/user/repo", {}, id="no-release"), ), ) def test_sync_without_releases_to_compare(changelog_path, repo_url, answer): """A repository off GitHub, an unhealthy API or no release syncs nothing.""" # A mock raises an exception found in its side effects, and returns the rest. with patch( "repomatic.github.release_sync.get_github_releases", side_effect=[answer] ): result = sync_github_releases(repo_url, changelog_path) assert result == SyncResult(dry_run=True)
[docs] def test_sync_live_failure(changelog_path): """Records failure when ``gh release edit`` fails.""" result, _edit_mock = run_sync( changelog_path, {"2.0.0": GitHubRelease(date="2026-01-15", body="old body")}, dry_run=False, edit_result=False, ) assert result.failed == 1 assert result.updated == 0 failed_rows = [r for r in result.rows if r.action == ReportAction.FAILED] assert len(failed_rows) == 1
[docs] def test_sync_live_error_moves_on_to_the_next_release(changelog_path): """A failed edit counts against its release, and the sync goes on. `edit_release_notes` raises on any failure but a missing release. Left uncaught, one transient error would abort the run and leave every release after it drifted. """ edit = Mock(side_effect=[GhCommandError("HTTP 502: Bad Gateway"), True]) with ( patch( "repomatic.github.release_sync.get_github_releases", return_value={ "2.0.0": GitHubRelease(date="2026-01-15", body="old body"), "1.0.0": GitHubRelease(date="2026-01-01", body="stale content"), }, ), patch("repomatic.github.release_sync.edit_release_notes", edit), ): result = sync_github_releases( "https://github.com/user/repo", changelog_path, dry_run=False ) assert edit.call_count == 2 assert result.failed == 1 assert result.updated == 1 assert [(row.version, row.action) for row in result.rows] == [ ("2.0.0", ReportAction.FAILED), ("1.0.0", ReportAction.UPDATED), ]
[docs] def test_render_sync_report_dry_run(): """Report format for dry-run mode.""" result = SyncResult( dry_run=True, total=5, in_sync=3, drifted=2, rows=[ SyncRow( action=ReportAction.SKIPPED, version="1.0.0", release_url="https://github.com/user/repo/releases/tag/v1.0.0", ), SyncRow( action=ReportAction.DRY_RUN, version="2.0.0", release_url="https://github.com/user/repo/releases/tag/v2.0.0", ), SyncRow( action=ReportAction.DRY_RUN, version="3.0.0", release_url="https://github.com/user/repo/releases/tag/v3.0.0", ), ], ) report = render_sync_report(result) assert "dry-run" in report assert "Total releases | 5" in report assert "In sync | 3" in report assert "Drifted | 2" in report # Skipped rows are excluded from details. assert "`1.0.0`" not in report assert "`2.0.0`" in report assert "`3.0.0`" in report # Dry-run mode doesn't show Updated/Failed counts. assert "Updated" not in report
[docs] def test_render_sync_report_live(): """Report format for live mode.""" result = SyncResult( dry_run=False, total=3, in_sync=1, drifted=2, updated=1, failed=1, rows=[ SyncRow( action=ReportAction.UPDATED, version="2.0.0", release_url="https://github.com/user/repo/releases/tag/v2.0.0", ), SyncRow( action=ReportAction.FAILED, version="3.0.0", release_url="https://github.com/user/repo/releases/tag/v3.0.0", ), ], ) report = render_sync_report(result) assert "live" in report assert "Updated | 1" in report assert "Failed | 1" in report
[docs] @pytest.mark.parametrize( ("setting", "synced"), ( pytest.param("", True, id="default"), pytest.param("github-releases.sync = false\n", False, id="switched-off"), ), ) def test_sync_github_releases_command_honors_its_switch( tmp_path, monkeypatch, setting, synced ): """`github-releases.sync = false` reads and writes no release.""" (tmp_path / "pyproject.toml").write_text( f"[tool.repomatic]\n{setting}", encoding="UTF-8" ) (tmp_path / "changelog.md").write_text(SAMPLE_CHANGELOG, encoding="UTF-8") monkeypatch.chdir(tmp_path) with patch( "repomatic.cli.release.sync_github_releases", return_value=SyncResult() ) as sync: result = CliRunner().invoke(repomatic, ["sync-github-releases", "--live"]) assert result.exit_code == 0, result.output assert sync.called is synced