Source code for tests.test_dev_release

# 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_deletes_all_dev_tags(): """Deletes all releases whose tag ends with .dev0.""" release_list = json.dumps([ {"tagName": "v6.2.0.dev0"}, {"tagName": "v6.1.1.dev0"}, {"tagName": "v6.1.0"}, {"tagName": "v6.0.1"}, ]) with patch( "repomatic.github.dev_release.run_gh_command", side_effect=[release_list, None, None], ) as mock_gh: cleanup_dev_releases("user/repo") # Should delete both dev releases, not the regular ones. assert mock_gh.call_count == 3 assert mock_gh.call_args_list[1] == call([ "release", "delete", "v6.2.0.dev0", "--cleanup-tag", "--yes", "--repo", "user/repo", ]) assert mock_gh.call_args_list[2] == call([ "release", "delete", "v6.1.1.dev0", "--cleanup-tag", "--yes", "--repo", "user/repo", ])
[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()