diff --git a/tests/test_release_doctor.py b/tests/test_release_doctor.py new file mode 100644 index 0000000..b5f5ff6 --- /dev/null +++ b/tests/test_release_doctor.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +import unittest + + +META_ROOT = Path(__file__).resolve().parents[1] +RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release" +SCRIPT = RELEASE_TOOLS_ROOT / "release-doctor.py" + +if str(RELEASE_TOOLS_ROOT) not in sys.path: + sys.path.insert(0, str(RELEASE_TOOLS_ROOT)) + + +def load_doctor_module(): + spec = importlib.util.spec_from_file_location("release_doctor", SCRIPT) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load {SCRIPT}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class ReleaseDoctorTests(unittest.TestCase): + def test_release_repo_names_include_catalog_and_migration_baseline_owners(self) -> None: + doctor = load_doctor_module() + + names = set(doctor.release_repo_names(META_ROOT.parent)) + + self.assertIn("govoplan-core", names) + self.assertIn("govoplan-files", names) + self.assertIn("govoplan-idm", names) + + def test_repo_findings_detect_dirty_repositories(self) -> None: + doctor = load_doctor_module() + repo = doctor.RepoState( + name="govoplan-files", + path="/tmp/govoplan-files", + exists=True, + dirty_entries=(" M pyproject.toml",), + pyproject_version="0.1.7", + local_tag_exists=True, + ) + + findings = doctor.repo_findings((repo,), target_version="0.1.7", target_tag="v0.1.7") + + self.assertEqual("blocker", findings[0].severity) + self.assertEqual("repo-dirty", findings[0].check_id) + self.assertIn("git status --short", [item.command for item in findings[0].commands]) + + def test_dubious_ownership_errors_suggest_safe_directory_command(self) -> None: + doctor = load_doctor_module() + repo = doctor.RepoState( + name="govoplan-files", + path="/tmp/govoplan-files", + exists=True, + safe_directory_required=True, + safe_directory_command="git config --global --add safe.directory /tmp/govoplan-files", + errors=("fatal: detected dubious ownership in repository",), + ) + + findings = doctor.repo_findings((repo,), target_version="0.1.7", target_tag="v0.1.7") + + self.assertEqual("repo-safe-directory", findings[0].check_id) + self.assertEqual("blocker", findings[0].severity) + self.assertTrue(findings[0].commands[0].mutating) + self.assertIn("safe.directory", findings[0].commands[0].command) + + def test_dubious_ownership_detection_matches_git_error(self) -> None: + doctor = load_doctor_module() + + self.assertTrue( + doctor.is_dubious_ownership_error( + "fatal: detected dubious ownership in repository at '/workspace/repo'\n" + "git config --global --add safe.directory /workspace/repo" + ) + ) + + def test_release_migration_strict_errors_suggest_recording_baseline(self) -> None: + doctor = load_doctor_module() + + findings = doctor.migration_findings( + { + "release": { + "ok": True, + "graph_errors": [], + "strict_errors": ["current migration heads are not recorded"], + }, + "dev": {"ok": True, "graph_errors": [], "strict_errors": []}, + }, + target_version="0.2.0", + ) + + self.assertEqual("migration-release-baseline", findings[0].check_id) + self.assertEqual("blocker", findings[0].severity) + commands = [item.command for item in findings[0].commands] + self.assertTrue(any("--record-release 0.2.0" in item for item in commands)) + + def test_catalog_findings_detect_missing_signatures(self) -> None: + doctor = load_doctor_module() + + findings = doctor.catalog_findings( + { + "catalog_exists": True, + "catalog_path": "/tmp/stable.json", + "core_release_version": "0.1.7", + "signature_count": 0, + "keyring_exists": True, + "key_count": 1, + }, + target_version="0.1.7", + online=False, + ) + + self.assertEqual("catalog-signatures", findings[0].check_id) + self.assertEqual("blocker", findings[0].severity) + + def test_suggested_commands_are_deduplicated(self) -> None: + doctor = load_doctor_module() + suggested = doctor.SuggestedCommand(title="Status", command="git status --short", cwd="/tmp/repo") + report = doctor.DoctorReport( + generated_at="2026-07-11T00:00:00Z", + workspace_root="/tmp", + target_version="0.1.7", + target_tag="v0.1.7", + online=False, + overall_status="blocked", + repos=(), + findings=( + doctor.Finding("a", "blocker", "A", "", (suggested,)), + doctor.Finding("b", "warning", "B", "", (suggested,)), + ), + ) + + self.assertEqual((suggested,), doctor.collect_suggested_commands(report)) + + def test_default_report_is_concise_and_keeps_details_out(self) -> None: + doctor = load_doctor_module() + repo = doctor.RepoState( + name="govoplan-files", + path="/tmp/govoplan-files", + exists=True, + dirty_entries=(" M pyproject.toml",), + ) + report = doctor.DoctorReport( + generated_at="2026-07-11T00:00:00Z", + workspace_root="/tmp", + target_version="0.1.7", + target_tag="v0.1.7", + online=False, + overall_status="blocked", + repos=(repo,), + findings=(doctor.Finding("repo-dirty", "blocker", "govoplan-files has uncommitted changes", "M pyproject.toml"),), + ) + + concise = doctor.render_text_report(report, detailed=False, include_commands=False) + detailed = doctor.render_text_report(report, detailed=True, include_commands=False) + + self.assertIn("dirty=1", concise) + self.assertNotIn("M pyproject.toml", concise) + self.assertIn("M pyproject.toml", detailed) + + def test_interactive_actions_offer_dirty_bulk_commit_push(self) -> None: + doctor = load_doctor_module() + repo = doctor.RepoState( + name="govoplan-files", + path="/tmp/govoplan-files", + exists=True, + dirty_entries=(" M pyproject.toml",), + ) + report = doctor.DoctorReport( + generated_at="2026-07-11T00:00:00Z", + workspace_root="/tmp", + target_version="0.1.7", + target_tag="v0.1.7", + online=False, + overall_status="blocked", + repos=(repo,), + findings=(), + ) + + actions = dict(doctor.interactive_actions(report)) + + self.assertIn("commit-push-dirty", actions) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_migration_audit.py b/tests/test_release_migration_audit.py new file mode 100644 index 0000000..958cdad --- /dev/null +++ b/tests/test_release_migration_audit.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +import tempfile +import unittest + + +META_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = META_ROOT / "tools" / "release" / "release-migration-audit.py" + + +def load_audit_module(): + spec = importlib.util.spec_from_file_location("release_migration_audit", SCRIPT) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load {SCRIPT}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class ReleaseMigrationAuditTests(unittest.TestCase): + def test_typed_alembic_variables_are_parsed(self) -> None: + audit = load_audit_module() + with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory: + path = Path(directory) / "1234_example.py" + path.write_text( + """ +from __future__ import annotations +from typing import Sequence, Union + +revision: str = "1234" +down_revision: Union[str, None] = "base" +depends_on: Union[str, None] = "core" +branch_labels: Union[str, Sequence[str], None] = None +""", + encoding="utf-8", + ) + + migration = audit.parse_migration_file("govoplan-core", path) + + self.assertIsNotNone(migration) + self.assertEqual(migration.revision, "1234") + self.assertEqual(migration.down_revisions, ("base",)) + self.assertEqual(migration.depends_on, ("core",)) + self.assertEqual(migration.branch_labels, ()) + + def test_release_baseline_matches_current_heads_in_strict_report(self) -> None: + audit = load_audit_module() + migrations = [ + audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()), + audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), (), ()), + ] + report = audit.build_report( + migrations, + baseline={ + "version": 1, + "releases": [ + { + "release": "0.1.0", + "heads": [{"owner": "govoplan-core", "revision": "head"}], + } + ], + }, + baseline_file=Path("docs/migration-release-baselines.json"), + workspace_root=Path("/workspace"), + ) + + self.assertEqual(report["current_heads"], ["head"]) + self.assertEqual(report["graph_errors"], []) + self.assertEqual(report["strict_errors"], []) + + def test_owner_heads_are_accepted_for_subset_installs(self) -> None: + audit = load_audit_module() + migrations = [ + audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()), + audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), (), ()), + ] + report = audit.build_report( + migrations, + baseline={ + "version": 1, + "releases": [ + { + "release": "0.1.0", + "heads": [{"owner": "govoplan-files", "revision": "files-head"}], + "owner_heads": [{"owner": "govoplan-core", "revisions": ["core-head"]}], + } + ], + }, + baseline_file=Path("docs/migration-release-baselines.json"), + workspace_root=Path("/workspace"), + ) + + self.assertEqual(report["current_heads"], ["core-head"]) + self.assertEqual(report["strict_errors"], []) + + def test_records_current_release_baseline(self) -> None: + audit = load_audit_module() + migrations = [ + audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()), + audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), (), ()), + audit.Migration("govoplan-files", Path("files_head.py"), "files-head", ("core-head",), (), ()), + ] + report = audit.build_report( + migrations, + baseline={"version": 1, "releases": []}, + baseline_file=Path("docs/migration-release-baselines.json"), + workspace_root=Path("/workspace"), + ) + + baseline = audit.record_release_baseline( + {"version": 1, "releases": []}, + report, + release="0.2.0", + replace=False, + ) + + release = baseline["releases"][0] + self.assertEqual(release["release"], "0.2.0") + self.assertEqual(release["squash_policy"], "reviewed-manual") + self.assertEqual(release["heads"], [{"owner": "govoplan-files", "revision": "files-head"}]) + self.assertIn({"owner": "govoplan-core", "revisions": ["core-head"]}, release["owner_heads"]) + + def test_missing_down_revision_is_a_graph_error(self) -> None: + audit = load_audit_module() + migrations = [ + audit.Migration("govoplan-core", Path("head.py"), "head", ("missing",), (), ()), + ] + report = audit.build_report( + migrations, + baseline={"version": 1, "releases": []}, + baseline_file=Path("docs/migration-release-baselines.json"), + workspace_root=Path("/workspace"), + ) + + self.assertIn("references missing down_revision", report["graph_errors"][0]) + + def test_depends_on_participates_in_graph_validation_and_heads(self) -> None: + audit = load_audit_module() + migrations = [ + audit.Migration("govoplan-core", Path("core.py"), "core", (), (), ()), + audit.Migration("govoplan-files", Path("files.py"), "files", (), ("core",), ()), + ] + report = audit.build_report( + migrations, + baseline={"version": 1, "releases": []}, + baseline_file=Path("docs/migration-release-baselines.json"), + workspace_root=Path("/workspace"), + ) + + self.assertEqual(report["graph_errors"], []) + self.assertEqual(report["current_heads"], ["files"]) + + def test_missing_depends_on_is_a_graph_error(self) -> None: + audit = load_audit_module() + migrations = [ + audit.Migration("govoplan-files", Path("files.py"), "files", (), ("missing",), ()), + ] + report = audit.build_report( + migrations, + baseline={"version": 1, "releases": []}, + baseline_file=Path("docs/migration-release-baselines.json"), + workspace_root=Path("/workspace"), + ) + + self.assertIn("references missing depends_on", report["graph_errors"][0]) + + def test_dev_track_does_not_emit_release_baseline_warnings(self) -> None: + audit = load_audit_module() + migrations = [ + audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()), + audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), (), ()), + ] + report = audit.build_report( + migrations, + baseline={"version": 1, "releases": []}, + baseline_file=Path("docs/migration-release-baselines.json"), + workspace_root=Path("/workspace"), + track="dev", + ) + + self.assertEqual(report["track"], "dev") + self.assertEqual(report["strict_errors"], []) + + +if __name__ == "__main__": + unittest.main()