mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* feat(release): add release-manager feature flag (default off) * feat(release): change classification + semver-bump derivation * feat(release): readiness audit (changelog/version-ref/docs/migration/gate) * feat(release): release-manager engine proposes a gated release * feat(release): fail-closed release executor (bump, gate, publish) * feat(release): CEO approve/reject release-proposal surface * docs(release): document the gated release manager * feat(memory): add org-memory feature flags (default off) * feat(memory): add playbooks table + status enum + migration * feat(memory): playbook service with auditor curation transitions * feat(memory): playbooks RAG index plugin * feat(memory): index a playbook into RAG on approval * feat(memory): distill a high-signal lesson at task completion * feat(memory): keep private journal reflections out of the shared RAG corpus * feat(memory): draft_playbook verb + auditor curation verbs * fix(ci): resolve mypy tests/ errors blocking the gate (UUID casts, annotations) * feat(memory): auto-inject similar lessons/playbooks into the briefing * feat(memory): auditor playbook review queue (api + panel) * docs(memory): document the org-memory loop + playbook verbs * fix(provisioning): idempotent pitch provisioning (reuse product/project by slug on re-approval) * fix(memory): add chunks_playbooks to the chunk schema + isolate release route tests - Migration 030's CHUNK_TABLES was missing chunks_playbooks, breaking the IndexType<->migration parity guard once the PLAYBOOKS index landed. The upgrade is ALTER ... IF EXISTS so adding it is safe on any DB shape. - The release-route fixture's approve/reject paths call db.commit() (real behavior), so a held proposal outlived the per-test rollback and leaked into engine tests that read the global list_open_release_proposals(). Tear down source=release_manager rows after each test. - Make the gather_snapshot real-repo smoke version-agnostic (semver match) so it stops pinning the literal repo version. * chore(release): 0.13.0 * ++ --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
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() == []
|