# 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_expected_body_includes_full_changelog_link():
"""The github-releases template appends a full changelog link."""
changelog = Changelog(SAMPLE_CHANGELOG)
body = build_expected_body(changelog, "2.0.0")
assert "**Full changelog**: [`v1.1.0...v2.0.0`](" in body
assert "compare/v1.1.0...v2.0.0)" 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