Feat/v0.13.0 (#270)

* 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>
This commit is contained in:
Renzo F
2026-06-26 01:43:08 +02:00
committed by GitHub
co-authored by Renn F
parent 153723406e
commit 5612375cba
87 changed files with 5032 additions and 91 deletions
@@ -0,0 +1,227 @@
"""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() == []