mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
228 lines
8.0 KiB
Python
228 lines
8.0 KiB
Python
"""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() == []
|