# Copyright Kevin Deldycke <kevin@deldycke.com> 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.
from __future__ import annotations
import json
from unittest.mock import call, patch
from repomatic.github.dev_release import (
_delete_release_assets,
_edit_dev_release,
cleanup_dev_releases,
delete_dev_release,
delete_release_by_tag,
sync_dev_release,
upload_release_assets,
)
UNRELEASED_CHANGELOG = """\
# Changelog
## [`6.1.1.dev0` (unreleased)](https://github.com/user/repo/compare/v6.1.0...main)
> [!WARNING]
> This version is **not released yet** and is under active development.
- New feature in progress.
## [`6.1.0` (2026-02-26)](https://github.com/user/repo/compare/v6.0.1...v6.1.0)
- Released feature.
"""
RELEASED_ONLY_CHANGELOG = """\
# Changelog
## [`6.1.0` (2026-02-26)](https://github.com/user/repo/compare/v6.0.1...v6.1.0)
- Released feature.
## [`6.0.1` (2026-02-24)](https://github.com/user/repo/compare/v6.0.0...v6.0.1)
- Bug fix.
"""
# --- sync_dev_release() tests ---
[docs]
def test_sync_dev_release_dry_run(tmp_path):
"""Dry-run reports without calling gh."""
changelog_path = tmp_path / "changelog.md"
changelog_path.write_text(UNRELEASED_CHANGELOG, encoding="UTF-8")
with patch(
"repomatic.github.dev_release.run_gh_command",
) as mock_gh:
result = sync_dev_release(
changelog_path,
"6.1.1.dev0",
"user/repo",
dry_run=True,
)
assert result is True
mock_gh.assert_not_called()
[docs]
def test_sync_dev_release_live(tmp_path):
"""Live mode creates new draft pre-release when none exists."""
changelog_path = tmp_path / "changelog.md"
changelog_path.write_text(UNRELEASED_CHANGELOG, encoding="UTF-8")
# gh release list returns no existing dev releases.
# Edit fails (no existing release), then create succeeds.
with patch(
"repomatic.github.dev_release.run_gh_command",
side_effect=[
json.dumps([]), # list releases for cleanup
RuntimeError("not found"), # edit fails (no existing release)
None, # create new release
],
) as mock_gh:
result = sync_dev_release(
changelog_path,
"6.1.1.dev0",
"user/repo",
dry_run=False,
)
assert result is True
# First call: list releases for cleanup.
assert mock_gh.call_args_list[0] == call([
"release",
"list",
"--json",
"tagName",
"--repo",
"user/repo",
])
# Second call: edit attempt (fails).
edit_call = mock_gh.call_args_list[1]
assert edit_call[0][0][:3] == ["release", "edit", "v6.1.1.dev0"]
# Third call: create new draft pre-release.
create_call = mock_gh.call_args_list[2]
assert create_call[0][0][:3] == ["release", "create", "v6.1.1.dev0"]
assert "--draft" in create_call[0][0]
assert "--prerelease" in create_call[0][0]
assert "--target" in create_call[0][0]
[docs]
def test_sync_dev_release_edits_existing(tmp_path):
"""Edits existing release to preserve assets instead of delete+recreate."""
changelog_path = tmp_path / "changelog.md"
changelog_path.write_text(UNRELEASED_CHANGELOG, encoding="UTF-8")
# gh release list returns the current dev release.
release_list = json.dumps([{"tagName": "v6.1.1.dev0"}])
with patch(
"repomatic.github.dev_release.run_gh_command",
side_effect=[
release_list, # list releases (current kept, not deleted)
None, # edit succeeds
],
) as mock_gh:
result = sync_dev_release(
changelog_path,
"6.1.1.dev0",
"user/repo",
dry_run=False,
)
assert result is True
assert mock_gh.call_count == 2
# Should NOT delete the current dev release.
delete_calls = [
c for c in mock_gh.call_args_list if c[0][0][0:2] == ["release", "delete"]
]
assert delete_calls == []
# Should edit, not create.
edit_call = mock_gh.call_args_list[1]
assert edit_call[0][0][:3] == ["release", "edit", "v6.1.1.dev0"]
assert "--title" in edit_call[0][0]
assert "--notes" in edit_call[0][0]
create_calls = [
c for c in mock_gh.call_args_list if c[0][0][0:2] == ["release", "create"]
]
assert create_calls == []
[docs]
def test_sync_dev_release_cleans_stale_releases(tmp_path):
"""Stale dev releases from previous versions are cleaned up."""
changelog_path = tmp_path / "changelog.md"
changelog_path.write_text(UNRELEASED_CHANGELOG, encoding="UTF-8")
# gh release list returns a stale dev release from a previous version.
release_list = json.dumps([
{"tagName": "v6.0.1.dev0"},
{"tagName": "v6.1.0"},
])
with patch(
"repomatic.github.dev_release.run_gh_command",
side_effect=[
release_list, # list releases
None, # delete v6.0.1.dev0 (stale)
RuntimeError("not found"), # edit fails (no existing release)
None, # create new release
],
) as mock_gh:
result = sync_dev_release(
changelog_path,
"6.1.1.dev0",
"user/repo",
dry_run=False,
)
assert result is True
# Should delete the stale dev release.
assert mock_gh.call_args_list[1] == call([
"release",
"delete",
"v6.0.1.dev0",
"--cleanup-tag",
"--yes",
"--repo",
"user/repo",
])
# Should not delete the non-dev release.
delete_tags = [
c[0][0][2]
for c in mock_gh.call_args_list
if c[0][0][0:2] == ["release", "delete"]
]
assert "v6.1.0" not in delete_tags
[docs]
def test_sync_dev_release_empty_body(tmp_path):
"""Returns False when changelog section produces an empty body."""
changelog_path = tmp_path / "changelog.md"
# Version exists in changelog but has no content.
changelog_path.write_text(RELEASED_ONLY_CHANGELOG, encoding="UTF-8")
with patch(
"repomatic.github.dev_release.run_gh_command",
) as mock_gh:
result = sync_dev_release(
changelog_path,
"9.9.9",
"user/repo",
dry_run=False,
)
assert result is False
mock_gh.assert_not_called()
[docs]
def test_sync_dev_release_body_content(tmp_path):
"""Verifies the release body includes changelog changes."""
changelog_path = tmp_path / "changelog.md"
changelog_path.write_text(UNRELEASED_CHANGELOG, encoding="UTF-8")
with patch(
"repomatic.github.dev_release.run_gh_command",
side_effect=[
json.dumps([]), # list releases for cleanup
RuntimeError("not found"), # edit fails (no existing release)
None, # create new release
],
) as mock_gh:
sync_dev_release(
changelog_path,
"6.1.1.dev0",
"user/repo",
dry_run=False,
)
# The create call's --notes argument should contain changes.
create_args = mock_gh.call_args_list[2][0][0]
notes_idx = create_args.index("--notes")
body = create_args[notes_idx + 1]
assert "New feature in progress." in body
# --- cleanup_dev_releases() tests ---
[docs]
def test_cleanup_dev_releases_no_dev_releases():
"""No-op when no dev releases exist."""
release_list = json.dumps([
{"tagName": "v6.1.0"},
{"tagName": "v6.0.1"},
])
with patch(
"repomatic.github.dev_release.run_gh_command",
return_value=release_list,
) as mock_gh:
cleanup_dev_releases("user/repo")
# Only the list call, no deletions.
mock_gh.assert_called_once()
[docs]
def test_cleanup_dev_releases_list_failure():
"""Silently succeeds when release listing fails."""
with patch(
"repomatic.github.dev_release.run_gh_command",
side_effect=RuntimeError("API error"),
):
# Should not raise.
cleanup_dev_releases("user/repo")
[docs]
def test_cleanup_dev_releases_delete_failure():
"""Continues cleaning when individual deletions fail."""
release_list = json.dumps([
{"tagName": "v6.2.0.dev0"},
{"tagName": "v6.1.1.dev0"},
])
with patch(
"repomatic.github.dev_release.run_gh_command",
side_effect=[
release_list,
RuntimeError("immutable"), # First delete fails.
None, # Second delete succeeds.
],
) as mock_gh:
cleanup_dev_releases("user/repo")
# Should attempt to delete both despite the first failure.
assert mock_gh.call_count == 3
[docs]
def test_cleanup_dev_releases_keeps_current_tag():
"""Preserves the current version's dev release when keep_tag is set."""
release_list = json.dumps([
{"tagName": "v6.2.0.dev0"},
{"tagName": "v6.1.1.dev0"},
{"tagName": "v6.1.0"},
])
with patch(
"repomatic.github.dev_release.run_gh_command",
side_effect=[release_list, None],
) as mock_gh:
cleanup_dev_releases("user/repo", keep_tag="v6.2.0.dev0")
# Should only delete the stale dev release, not the kept one.
assert mock_gh.call_count == 2
assert mock_gh.call_args_list[1] == call([
"release",
"delete",
"v6.1.1.dev0",
"--cleanup-tag",
"--yes",
"--repo",
"user/repo",
])
# --- _edit_dev_release() tests ---
[docs]
def test_edit_dev_release_success():
"""Edits an existing release and returns True."""
with patch(
"repomatic.github.dev_release.run_gh_command",
) as mock_gh:
result = _edit_dev_release("v6.1.1.dev0", "6.1.1.dev0", "body", "user/repo")
assert result is True
mock_gh.assert_called_once_with([
"release",
"edit",
"v6.1.1.dev0",
"--title",
"6.1.1.dev0",
"--notes",
"body",
"--repo",
"user/repo",
])
[docs]
def test_edit_dev_release_not_found():
"""Returns False when the release does not exist."""
with patch(
"repomatic.github.dev_release.run_gh_command",
side_effect=RuntimeError("release not found"),
):
result = _edit_dev_release("v6.1.1.dev0", "6.1.1.dev0", "body", "user/repo")
assert result is False
# --- delete_dev_release() tests ---
[docs]
def test_delete_dev_release_success():
"""Calls gh release delete with correct arguments."""
with patch(
"repomatic.github.dev_release.run_gh_command",
) as mock_gh:
delete_dev_release("6.1.1.dev0", "user/repo")
mock_gh.assert_called_once_with([
"release",
"delete",
"v6.1.1.dev0",
"--cleanup-tag",
"--yes",
"--repo",
"user/repo",
])
[docs]
def test_delete_dev_release_missing():
"""Silently succeeds when no dev release exists."""
with patch(
"repomatic.github.dev_release.run_gh_command",
side_effect=RuntimeError("release not found"),
):
# Should not raise.
delete_dev_release("6.1.1.dev0", "user/repo")
# --- delete_release_by_tag() tests ---
[docs]
def test_delete_release_by_tag_success():
"""Calls gh release delete with the given tag."""
with patch(
"repomatic.github.dev_release.run_gh_command",
) as mock_gh:
delete_release_by_tag("v6.1.1.dev0", "user/repo")
mock_gh.assert_called_once_with([
"release",
"delete",
"v6.1.1.dev0",
"--cleanup-tag",
"--yes",
"--repo",
"user/repo",
])
[docs]
def test_delete_release_by_tag_immutable():
"""Silently succeeds for immutable published releases."""
with patch(
"repomatic.github.dev_release.run_gh_command",
side_effect=RuntimeError("HTTP 422"),
):
# Should not raise.
delete_release_by_tag("v6.1.1.dev0", "user/repo")
# --- _delete_release_assets() tests ---
[docs]
def test_delete_release_assets_success():
"""Deletes assets and returns count."""
assets_json = json.dumps({
"assets": [
{"apiUrl": "https://api.github.com/repos/user/repo/releases/assets/111"},
{"apiUrl": "https://api.github.com/repos/user/repo/releases/assets/222"},
],
})
with patch(
"repomatic.github.dev_release.run_gh_command",
side_effect=[assets_json, None, None],
) as mock_gh:
result = _delete_release_assets("v6.1.1.dev0", "user/repo")
assert result == 2
assert mock_gh.call_args_list[1] == call([
"api",
"--method",
"DELETE",
"repos/user/repo/releases/assets/111",
])
assert mock_gh.call_args_list[2] == call([
"api",
"--method",
"DELETE",
"repos/user/repo/releases/assets/222",
])
[docs]
def test_delete_release_assets_no_release():
"""Returns 0 when the release does not exist."""
with patch(
"repomatic.github.dev_release.run_gh_command",
side_effect=RuntimeError("not found"),
):
result = _delete_release_assets("v6.1.1.dev0", "user/repo")
assert result == 0
[docs]
def test_delete_release_assets_empty():
"""Returns 0 when the release has no assets."""
with patch(
"repomatic.github.dev_release.run_gh_command",
return_value=json.dumps({"assets": []}),
):
result = _delete_release_assets("v6.1.1.dev0", "user/repo")
assert result == 0
[docs]
def test_delete_release_assets_partial_failure():
"""Continues when individual asset deletions fail."""
assets_json = json.dumps({
"assets": [
{"apiUrl": "https://api.github.com/repos/user/repo/releases/assets/111"},
{"apiUrl": "https://api.github.com/repos/user/repo/releases/assets/222"},
],
})
with patch(
"repomatic.github.dev_release.run_gh_command",
side_effect=[
assets_json,
RuntimeError("forbidden"),
None,
],
) as mock_gh:
result = _delete_release_assets("v6.1.1.dev0", "user/repo")
assert result == 1
assert mock_gh.call_count == 3
# --- upload_release_assets() tests ---
[docs]
def test_upload_release_assets_success(tmp_path):
"""Uploads matching files and skips unrelated ones."""
# Create matching files.
(tmp_path / "repomatic-6.2.0-linux-x64.bin").touch()
(tmp_path / "repomatic-6.2.0.tar.gz").touch()
# Create a non-matching file.
(tmp_path / "readme.txt").touch()
assets_json = json.dumps({"assets": []})
with patch(
"repomatic.github.dev_release.run_gh_command",
side_effect=[assets_json, None],
) as mock_gh:
result = upload_release_assets("v6.2.0.dev0", "user/repo", tmp_path)
assert len(result) == 2
assert all(f.suffix in (".bin", ".gz") for f in result)
# Last call should be the upload.
upload_call = mock_gh.call_args_list[-1]
assert upload_call[0][0][:3] == ["release", "upload", "v6.2.0.dev0"]
assert "--clobber" in upload_call[0][0]
[docs]
def test_upload_release_assets_no_files(tmp_path):
"""Returns empty list and makes no gh calls when no matching files."""
(tmp_path / "readme.txt").touch()
with patch(
"repomatic.github.dev_release.run_gh_command",
) as mock_gh:
result = upload_release_assets("v6.2.0.dev0", "user/repo", tmp_path)
assert result == []
mock_gh.assert_not_called()
[docs]
def test_upload_release_assets_deletes_existing_first(tmp_path):
"""Deletes existing assets before uploading new ones."""
(tmp_path / "pkg-1.0.0.whl").touch()
assets_json = json.dumps({
"assets": [
{"apiUrl": "https://api.github.com/repos/user/repo/releases/assets/999"},
],
})
with patch(
"repomatic.github.dev_release.run_gh_command",
side_effect=[
assets_json, # view assets
None, # delete asset 999
None, # upload
],
) as mock_gh:
result = upload_release_assets("v6.2.0.dev0", "user/repo", tmp_path)
assert len(result) == 1
# First call: view release assets.
assert mock_gh.call_args_list[0][0][0][:3] == ["release", "view", "v6.2.0.dev0"]
# Second call: delete existing asset.
assert mock_gh.call_args_list[1] == call([
"api",
"--method",
"DELETE",
"repos/user/repo/releases/assets/999",
])
# Third call: upload.
assert mock_gh.call_args_list[2][0][0][:3] == ["release", "upload", "v6.2.0.dev0"]
# --- sync_dev_release() with assets tests ---
[docs]
def test_sync_dev_release_with_assets(tmp_path):
"""End-to-end: metadata sync + asset upload."""
changelog_path = tmp_path / "changelog.md"
changelog_path.write_text(UNRELEASED_CHANGELOG, encoding="UTF-8")
asset_dir = tmp_path / "assets"
asset_dir.mkdir()
(asset_dir / "repomatic-6.1.1.dev0.tar.gz").touch()
with patch(
"repomatic.github.dev_release.run_gh_command",
side_effect=[
json.dumps([]), # list releases for cleanup
RuntimeError("not found"), # edit fails
None, # create release
json.dumps({"assets": []}), # view assets (empty)
None, # upload
],
) as mock_gh:
result = sync_dev_release(
changelog_path,
"6.1.1.dev0",
"user/repo",
dry_run=False,
asset_dir=asset_dir,
)
assert result is True
# Last call should be the upload.
upload_call = mock_gh.call_args_list[-1]
assert upload_call[0][0][:3] == ["release", "upload", "v6.1.1.dev0"]
[docs]
def test_sync_dev_release_dry_run_with_assets(tmp_path):
"""Dry-run previews asset files without making gh calls."""
changelog_path = tmp_path / "changelog.md"
changelog_path.write_text(UNRELEASED_CHANGELOG, encoding="UTF-8")
asset_dir = tmp_path / "assets"
asset_dir.mkdir()
(asset_dir / "repomatic-6.1.1.dev0.whl").touch()
(asset_dir / "repomatic-6.1.1.dev0.tar.gz").touch()
with patch(
"repomatic.github.dev_release.run_gh_command",
) as mock_gh:
result = sync_dev_release(
changelog_path,
"6.1.1.dev0",
"user/repo",
dry_run=True,
asset_dir=asset_dir,
)
assert result is True
mock_gh.assert_not_called()