"""Release-manager engine: propose a CEO-gated release, held + deduped, never publish. Mirrors the self-heal engine tests. The engine proposes only past the threshold + green gate, holds the proposal for the CEO (confirmed_by_human=False, owned by the Secretary, never dispatched), dedupes to one open proposal, and NEVER publishes / approves — asserted here against a real Postgres DB. """ from __future__ import annotations import subprocess from typing import TYPE_CHECKING from unittest.mock import AsyncMock import pytest from roboco.config import settings as cfg from roboco.db.tables import AgentTable, ProjectTable from roboco.foundation import identity as _foundation from roboco.foundation.policy.content import markers from roboco.models.base import AgentRole, AgentStatus, Team from roboco.models.base import TaskStatus as TS from roboco.services.notification import NotificationService from roboco.services.release_manager_engine import ReleaseAssessor, ReleaseManagerEngine from roboco.services.release_readiness import ( BumpKind, Gap, ReleaseReadinessReport, report_from_dict, report_to_dict, ) from roboco.services.task import RELEASE_MANAGER_SOURCE, TaskService, get_task_service if TYPE_CHECKING: from pathlib import Path from sqlalchemy.ext.asyncio import AsyncSession SYSTEM_UUID = _foundation.AGENTS["system"].uuid SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid SLUG = "roboco" ONE = 1 MIN_COMMITS = 8 _VERSION = "0.13.0" def _report( *, bump: BumpKind = "minor", gate: str = "green", kind: str = "feat", n_commits: int = 10, gaps: list[Gap] | None = None, ) -> ReleaseReadinessReport: return ReleaseReadinessReport( proposed_version=_VERSION, bump_kind=bump, change_summary=[f"{kind}: change {i}" for i in range(n_commits)], drafted_changelog=f"## [{_VERSION}] - 2026-06-25\n\n### Added\n- stuff (#1)\n", version_bump_plan=["pyproject.toml"], gaps=gaps or [], migration_notes=[], gate_state=gate, ) def _assessor(report: ReleaseReadinessReport | None) -> ReleaseAssessor: async def _a() -> ReleaseReadinessReport | None: return report return _a async def _seed(session: AsyncSession) -> None: for uuid, slug, role, team in ( (SYSTEM_UUID, "system", AgentRole.SYSTEM, None), (SECRETARY_UUID, "secretary-1", AgentRole.SECRETARY, None), ): if await session.get(AgentTable, uuid) is None: session.add( AgentTable( id=uuid, name=slug, slug=slug, role=role, team=team, status=AgentStatus.ACTIVE, model_config={}, system_prompt="x", capabilities=[], permissions={}, metrics={}, ) ) await session.flush() session.add( ProjectTable( name="RoboCo", slug=SLUG, git_url="https://github.com/x/roboco.git", default_branch="master", protected_branches=["master"], assigned_cell=Team.BACKEND, created_by=SYSTEM_UUID, is_active=True, ) ) await session.flush() def _enable(monkeypatch: pytest.MonkeyPatch, **overrides: object) -> None: monkeypatch.setattr(cfg, "release_manager_enabled", True) monkeypatch.setattr(cfg, "release_min_commits", MIN_COMMITS) monkeypatch.setattr(cfg, "self_heal_project_slug", SLUG) for key, value in overrides.items(): monkeypatch.setattr(cfg, key, value) monkeypatch.setattr(NotificationService, "send_ack_notification", AsyncMock()) def test_report_dict_round_trip() -> None: report = _report(gaps=[Gap("gate", "x"), Gap("changelog", "y")]) assert report_from_dict(report_to_dict(report)) == report @pytest.mark.asyncio async def test_disabled_creates_no_proposal( db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: await _seed(db_session) monkeypatch.setattr(cfg, "release_manager_enabled", False) engine = ReleaseManagerEngine(db_session, assessor=_assessor(_report())) assert await engine.run_cycle() is None assert await get_task_service(db_session).list_open_release_proposals() == [] @pytest.mark.asyncio async def test_below_threshold_no_proposal( db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: await _seed(db_session) _enable(monkeypatch) # Patch bump + few fix commits + no security → below the threshold. report = _report(bump="patch", kind="fix", n_commits=2) engine = ReleaseManagerEngine(db_session, assessor=_assessor(report)) assert await engine.run_cycle() is None assert await get_task_service(db_session).list_open_release_proposals() == [] @pytest.mark.asyncio async def test_red_gate_no_proposal( db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: await _seed(db_session) _enable(monkeypatch) engine = ReleaseManagerEngine(db_session, assessor=_assessor(_report(gate="red"))) assert await engine.run_cycle() is None assert await get_task_service(db_session).list_open_release_proposals() == [] @pytest.mark.asyncio async def test_proposes_held_proposal_past_threshold( db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: await _seed(db_session) _enable(monkeypatch) engine = ReleaseManagerEngine(db_session, assessor=_assessor(_report())) task = await engine.run_cycle() assert task is not None open_proposals = await get_task_service(db_session).list_open_release_proposals() assert len(open_proposals) == ONE proposal = open_proposals[0] assert proposal.status == TS.PENDING assert proposal.confirmed_by_human is False # HELD for the CEO, not dispatched assert proposal.assigned_to == SECRETARY_UUID assert proposal.source == RELEASE_MANAGER_SOURCE assert "0.13.0" in proposal.title stored = markers.get_release_report(proposal) assert stored is not None assert report_from_dict(stored).proposed_version == "0.13.0" @pytest.mark.asyncio async def test_security_only_patch_still_proposes( db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: await _seed(db_session) _enable(monkeypatch) # One security fix (patch bump, below the commit floor) still warrants a release. report = _report(bump="patch", kind="security", n_commits=1) engine = ReleaseManagerEngine(db_session, assessor=_assessor(report)) assert await engine.run_cycle() is not None @pytest.mark.asyncio async def test_dedupe_one_open_proposal( db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: await _seed(db_session) _enable(monkeypatch) await ReleaseManagerEngine(db_session, assessor=_assessor(_report())).run_cycle() await ReleaseManagerEngine(db_session, assessor=_assessor(_report())).run_cycle() assert len(await get_task_service(db_session).list_open_release_proposals()) == ONE @pytest.mark.asyncio async def test_loop_never_publishes_or_approves( db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: await _seed(db_session) _enable(monkeypatch) approve = AsyncMock() ceo_approve = AsyncMock() monkeypatch.setattr(TaskService, "approve_and_start", approve) monkeypatch.setattr(TaskService, "ceo_approve", ceo_approve) await ReleaseManagerEngine(db_session, assessor=_assessor(_report())).run_cycle() approve.assert_not_awaited() ceo_approve.assert_not_awaited() proposals = await get_task_service(db_session).list_open_release_proposals() assert proposals[0].status == TS.PENDING # never advanced by the loop @pytest.mark.asyncio async def test_none_assessment_no_proposal( db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: await _seed(db_session) _enable(monkeypatch) engine = ReleaseManagerEngine(db_session, assessor=_assessor(None)) assert await engine.run_cycle() is None assert await get_task_service(db_session).list_open_release_proposals() == [] # --- _production_assess: pass head_sha to the CI gate (M8) --- def _git(root: Path, *args: str) -> str: result = subprocess.run( ["git", "-C", str(root), *args], capture_output=True, text=True, check=True, ) return result.stdout def _read_clone_repo(tmp_path: Path) -> Path: """A minimal git repo standing in for the read clone with a known HEAD.""" root = tmp_path / "read-clone" root.mkdir() _git(root, "init") _git(root, "config", "user.name", "Test") _git(root, "config", "user.email", "test@example.com") (root / "pyproject.toml").write_text('version = "0.1.0"\n', encoding="utf-8") (root / "CHANGELOG.md").write_text("## [Unreleased]\n", encoding="utf-8") (root / "README.md").write_text("hi\n", encoding="utf-8") _git(root, "add", "-A") _git(root, "commit", "-m", "feat: seed") return root class _FakeProjectService: def __init__(self, project: object) -> None: self._project = project async def get_by_slug(self, _slug: str) -> object: return self._project @pytest.mark.asyncio async def test_production_assess_passes_head_sha_to_ci_gate( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: _enable(monkeypatch) clone = _read_clone_repo(tmp_path) head_sha = subprocess.run( ["git", "-C", str(clone), "rev-parse", "HEAD"], capture_output=True, text=True, check=True, ).stdout.strip() project = type("P", (), {"slug": SLUG, "git_url": "https://x/y.git"})() monkeypatch.setattr( "roboco.services.release_manager_engine.get_project_service", lambda _session: _FakeProjectService(project), ) monkeypatch.setattr( "roboco.services.workspace.get_workspace_service", lambda _session: type( "WS", (), {"ensure_read_clone": AsyncMock(return_value=clone)}, )(), ) captured: dict[str, object] = {} async def _fake_ci( _self: object, _slug: str, **_kwargs: object ) -> dict[str, str] | None: captured["head_sha"] = _kwargs.get("head_sha") return {"conclusion": "success", "head_sha": str(_kwargs.get("head_sha") or "")} monkeypatch.setattr( "roboco.services.git.get_git_service", lambda _session: type("GS", (), {"get_latest_ci_conclusion": _fake_ci})(), ) engine = ReleaseManagerEngine(session=AsyncMock()) # real _production_assess report = await engine._production_assess() assert report is not None assert captured["head_sha"] == head_sha assert report.gate_state == "green" @pytest.mark.asyncio async def test_production_assess_head_unresolvable_passes_none( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: _enable(monkeypatch) # A non-git directory: `git rev-parse HEAD` fails, so head_sha is None. bogus = tmp_path / "not-a-repo" bogus.mkdir() # gather_snapshot reads pyproject.toml + CHANGELOG.md before any git call; # populate them so the snapshot build doesn't crash on file reads (git # commands run with check=False and silently return "" on a non-repo). (bogus / "pyproject.toml").write_text('version = "0.1.0"\n', encoding="utf-8") (bogus / "CHANGELOG.md").write_text("## [Unreleased]\n", encoding="utf-8") project = type("P", (), {"slug": SLUG, "git_url": "https://x/y.git"})() monkeypatch.setattr( "roboco.services.release_manager_engine.get_project_service", lambda _session: _FakeProjectService(project), ) monkeypatch.setattr( "roboco.services.workspace.get_workspace_service", lambda _session: type( "WS", (), {"ensure_read_clone": AsyncMock(return_value=bogus)}, )(), ) captured: dict[str, object] = {} async def _fake_ci( _self: object, _slug: str, **_kwargs: object ) -> dict[str, str] | None: captured["head_sha"] = _kwargs.get("head_sha") return None # no CI signal → unknown gate monkeypatch.setattr( "roboco.services.git.get_git_service", lambda _session: type("GS", (), {"get_latest_ci_conclusion": _fake_ci})(), ) engine = ReleaseManagerEngine(session=AsyncMock()) report = await engine._production_assess() # head_sha=None flowed through; gate is unknown (a gap, no proposal). assert captured["head_sha"] is None assert report is not None assert report.gate_state == "unknown"