Files
roboco/tests/unit/services/test_release_manager_engine.py
T

228 lines
8.0 KiB
Python
Raw Normal View History

2026-06-26 01:43:08 +02:00
"""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
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 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() == []