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
@@ -8,8 +8,8 @@ gets at most one open fix task even across its cell-projects.
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
from typing import TYPE_CHECKING, cast
from uuid import UUID, uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
@@ -89,7 +89,7 @@ async def _make_ci_watch_task(
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
project_id=project.id,
project_id=cast("UUID", project.id),
status=TaskStatus.PENDING,
source=source,
confirmed_by_human=True,
@@ -7,8 +7,8 @@ monorepo gets at most one open dependency-update task across its cell-projects.
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
from typing import TYPE_CHECKING, cast
from uuid import UUID, uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
@@ -87,7 +87,7 @@ async def _make_task(
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
project_id=project.id,
project_id=cast("UUID", project.id),
status=TaskStatus.PENDING,
source=source,
confirmed_by_human=True,
@@ -8,8 +8,8 @@ once per cell-project after a re-point). Re-review on a new head SHA still works
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID, uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
@@ -72,12 +72,18 @@ async def test_sibling_project_same_pr_is_deduped(db_session: AsyncSession) -> N
svc = get_task_service(db_session)
first = await svc.ingest_external_pr(
project_id=fe.id, pr=_pr("abc123"), created_by=SYSTEM_UUID, team=Team.FRONTEND
project_id=cast("UUID", fe.id),
pr=_pr("abc123"),
created_by=SYSTEM_UUID,
team=Team.FRONTEND,
)
assert first is not None # first review opens
# Same PR + same head, sibling project on the SAME repo → no second review.
dup = await svc.ingest_external_pr(
project_id=be.id, pr=_pr("abc123"), created_by=SYSTEM_UUID, team=Team.BACKEND
project_id=cast("UUID", be.id),
pr=_pr("abc123"),
created_by=SYSTEM_UUID,
team=Team.BACKEND,
)
assert dup is None
@@ -88,12 +94,16 @@ async def test_exists_is_repo_scoped(db_session: AsyncSession) -> None:
be = await _seed(db_session, "gca-backend", _REPO)
svc = get_task_service(db_session)
await svc.ingest_external_pr(
project_id=fe.id, pr=_pr("abc123"), created_by=SYSTEM_UUID, team=Team.FRONTEND
project_id=cast("UUID", fe.id),
pr=_pr("abc123"),
created_by=SYSTEM_UUID,
team=Team.FRONTEND,
)
# The sibling project sees the existing review (the fix); a new head SHA does not.
assert await svc.external_review_task_exists(be.id, 131, "abc123") is True
assert await svc.external_review_task_exists(be.id, 131, "newsha") is False
be_id = cast("UUID", be.id)
assert await svc.external_review_task_exists(be_id, 131, "abc123") is True
assert await svc.external_review_task_exists(be_id, 131, "newsha") is False
@pytest.mark.asyncio
@@ -102,11 +112,17 @@ async def test_different_repo_not_deduped(db_session: AsyncSession) -> None:
other = await _seed(db_session, "other", _OTHER_REPO)
svc = get_task_service(db_session)
await svc.ingest_external_pr(
project_id=fe.id, pr=_pr("abc123"), created_by=SYSTEM_UUID, team=Team.FRONTEND
project_id=cast("UUID", fe.id),
pr=_pr("abc123"),
created_by=SYSTEM_UUID,
team=Team.FRONTEND,
)
# A genuinely different repo with the same PR number is reviewed independently.
created = await svc.ingest_external_pr(
project_id=other.id, pr=_pr("abc123"), created_by=SYSTEM_UUID, team=Team.BACKEND
project_id=cast("UUID", other.id),
pr=_pr("abc123"),
created_by=SYSTEM_UUID,
team=Team.BACKEND,
)
assert created is not None
@@ -7,8 +7,8 @@ operator can't opt a project in.
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
from typing import TYPE_CHECKING, cast
from uuid import UUID, uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
@@ -55,7 +55,7 @@ async def test_update_sets_autonomy_opt_ins(db_session: AsyncSession) -> None:
svc = get_project_service(db_session)
await svc.update(
project.id,
cast("UUID", project.id),
ProjectUpdate(
ci_watch_enabled=True,
ci_watch_workflow="ci.yml",
@@ -64,7 +64,7 @@ async def test_update_sets_autonomy_opt_ins(db_session: AsyncSession) -> None:
),
)
reloaded = await svc.get(project.id)
reloaded = await svc.get(cast("UUID", project.id))
assert reloaded is not None
assert reloaded.ci_watch_enabled is True
assert reloaded.ci_watch_workflow == "ci.yml"
+48
View File
@@ -509,6 +509,54 @@ async def test_create_entry_learning_calls_record_learning(
mock_optimal.record_learning.assert_awaited()
@pytest.mark.asyncio
async def test_nonprivate_entry_is_indexed_to_shared_corpus(
journal_setup: dict,
) -> None:
"""A non-private reflection is embedded into the shared JOURNALS index."""
svc = journal_setup["svc"]
journal = await svc.get_or_create_journal(journal_setup["agent_id"])
mock_optimal = _AsyncMock()
mock_optimal.index_journal_entry = _AsyncMock(return_value=None)
svc._optimal_service = mock_optimal
await svc.create_entry(
JournalEntryCreate(
journal_id=journal.id,
type=JournalEntryType.DECISION_LOG,
title="t",
content="a shareable decision",
is_private=False,
)
)
await drain_rag_index_tasks()
mock_optimal.index_journal_entry.assert_awaited()
@pytest.mark.asyncio
async def test_private_entry_not_indexed_to_shared_corpus(
journal_setup: dict,
) -> None:
"""A PRIVATE reflection must NOT enter the cross-agent JOURNALS index."""
svc = journal_setup["svc"]
journal = await svc.get_or_create_journal(journal_setup["agent_id"])
mock_optimal = _AsyncMock()
mock_optimal.index_journal_entry = _AsyncMock(return_value=None)
svc._optimal_service = mock_optimal
await svc.create_entry(
JournalEntryCreate(
journal_id=journal.id,
type=JournalEntryType.DECISION_LOG,
title="t",
content="a private reflection",
is_private=True,
)
)
await drain_rag_index_tasks()
mock_optimal.index_journal_entry.assert_not_awaited()
# ---------------------------------------------------------------------------
# list_entries — filter by task_id
# ---------------------------------------------------------------------------
+112
View File
@@ -0,0 +1,112 @@
"""Playbook curation routes — Auditor/CEO list + approve/reject; others 403."""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.playbooks import router as playbooks_router
from roboco.models import AgentRole
from roboco.models.permissions import AgentContext
from roboco.models.playbook import PlaybookCreate
from roboco.services.playbook import PlaybookService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
def _build_app(db_session: AsyncSession, role: AgentRole, agent_id: UUID) -> FastAPI:
app = FastAPI()
app.include_router(playbooks_router, prefix="/api/playbooks")
async def _override_db() -> AsyncIterator[AsyncSession]:
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(agent_id=agent_id, role=role, team=None)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
return app
async def _seed_draft(db_session: AsyncSession, title: str = "Retry flaky pg") -> UUID:
pb = await PlaybookService(db_session).draft(
PlaybookCreate(
title=title,
problem="connection resets intermittently",
procedure="1. retry with backoff",
tags=["backend"],
),
created_by=uuid4(),
)
return pb.id
@pytest_asyncio.fixture
async def auditor_client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
app = _build_app(db_session, AgentRole.AUDITOR, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_list_drafts_as_auditor(
db_session: AsyncSession, auditor_client: AsyncClient
) -> None:
await _seed_draft(db_session, title="Draft to list")
resp = await auditor_client.get("/api/playbooks", params={"status": "draft"})
assert resp.status_code == HTTPStatus.OK
titles = [p["title"] for p in resp.json()]
assert "Draft to list" in titles
@pytest.mark.asyncio
async def test_approve_as_auditor_flips_to_approved(
db_session: AsyncSession, auditor_client: AsyncClient
) -> None:
pid = await _seed_draft(db_session, title="Approve me")
resp = await auditor_client.post(f"/api/playbooks/{pid}/approve")
assert resp.status_code == HTTPStatus.OK
assert resp.json()["status"] == "approved"
@pytest.mark.asyncio
async def test_reject_as_auditor_archives(
db_session: AsyncSession, auditor_client: AsyncClient
) -> None:
pid = await _seed_draft(db_session, title="Reject me")
resp = await auditor_client.post(
f"/api/playbooks/{pid}/reject", json={"reason": "duplicate of an existing one"}
)
assert resp.status_code == HTTPStatus.OK
assert resp.json()["status"] == "archived"
@pytest.mark.asyncio
async def test_approve_missing_is_404(auditor_client: AsyncClient) -> None:
resp = await auditor_client.post(f"/api/playbooks/{uuid4()}/approve")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_non_curator_is_forbidden(db_session: AsyncSession) -> None:
pid = await _seed_draft(db_session, title="Guarded")
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
get_resp = await client.get("/api/playbooks")
approve_resp = await client.post(f"/api/playbooks/{pid}/approve")
assert get_resp.status_code == HTTPStatus.FORBIDDEN
assert approve_resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
+145
View File
@@ -0,0 +1,145 @@
"""PlaybookService — draft + Auditor curation transitions (real Postgres)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from roboco.config import settings as cfg
from roboco.models.base import PlaybookStatus
from roboco.models.playbook import PlaybookCreate
from roboco.services.base import ConflictError, NotFoundError
from roboco.services.playbook import PlaybookService
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
def _create(title: str = "Retry a flaky pg test", **kw: Any) -> PlaybookCreate:
base: dict[str, Any] = {
"title": title,
"problem": "A pg integration test fails intermittently on connection reset.",
"procedure": "1. Wrap the fixture in a retry.\n2. Assert idempotency.",
"tags": ["backend"],
"scope": "org",
}
base.update(kw)
return PlaybookCreate(**base)
@pytest.mark.asyncio
async def test_draft_creates_a_draft_with_derived_slug(
db_session: AsyncSession,
) -> None:
svc = PlaybookService(db_session)
pb = await svc.draft(_create(title="Retry Flaky PG!"), created_by=uuid4())
assert pb.status == PlaybookStatus.DRAFT
assert pb.slug == "retry-flaky-pg"
@pytest.mark.asyncio
async def test_draft_then_approve_flips_status_and_stamps(
db_session: AsyncSession,
) -> None:
svc = PlaybookService(db_session)
auditor = uuid4()
pb = await svc.draft(_create(), created_by=uuid4())
approved = await svc.approve(pb.id, approver_id=auditor)
assert approved.status == PlaybookStatus.APPROVED
assert approved.approved_by == auditor
assert approved.approved_at is not None
@pytest.mark.asyncio
async def test_reject_archives(db_session: AsyncSession) -> None:
svc = PlaybookService(db_session)
pb = await svc.draft(_create(), created_by=uuid4())
out = await svc.reject(
pb.id, approver_id=uuid4(), reason="duplicate of an existing one"
)
assert out.status == PlaybookStatus.ARCHIVED
@pytest.mark.asyncio
async def test_list_drafts_and_approved_partition(db_session: AsyncSession) -> None:
svc = PlaybookService(db_session)
d = await svc.draft(_create(title="Draft one"), created_by=uuid4())
a = await svc.draft(_create(title="Approved one"), created_by=uuid4())
await svc.approve(a.id, approver_id=uuid4())
draft_ids = {p.id for p in await svc.list_drafts()}
approved_ids = {p.id for p in await svc.list_approved()}
assert d.id in draft_ids and d.id not in approved_ids
assert a.id in approved_ids and a.id not in draft_ids
@pytest.mark.asyncio
async def test_duplicate_slug_raises_conflict(db_session: AsyncSession) -> None:
svc = PlaybookService(db_session)
await svc.draft(_create(title="Same Title"), created_by=uuid4())
with pytest.raises(ConflictError):
await svc.draft(_create(title="Same Title"), created_by=uuid4())
@pytest.mark.asyncio
async def test_approve_missing_raises_notfound(db_session: AsyncSession) -> None:
svc = PlaybookService(db_session)
with pytest.raises(NotFoundError):
await svc.approve(uuid4(), approver_id=uuid4())
@pytest.mark.asyncio
async def test_source_task_id_is_recorded(db_session: AsyncSession) -> None:
svc = PlaybookService(db_session)
task_id = uuid4()
pb = await svc.draft(_create(source_task_id=task_id), created_by=uuid4())
assert str(task_id) in pb.source_task_ids
@pytest.mark.asyncio
async def test_approve_indexes_when_org_memory_on(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "org_memory_enabled", True)
fake_optimal = AsyncMock()
fake_optimal.index_playbook = AsyncMock()
monkeypatch.setattr(
"roboco.services.optimal.get_optimal_service",
AsyncMock(return_value=fake_optimal),
)
svc = PlaybookService(db_session)
pb = await svc.draft(_create(title="Index me"), created_by=uuid4())
await svc.approve(pb.id, approver_id=uuid4())
fake_optimal.index_playbook.assert_awaited_once()
@pytest.mark.asyncio
async def test_approve_does_not_index_when_off(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "org_memory_enabled", False)
getter = AsyncMock()
monkeypatch.setattr("roboco.services.optimal.get_optimal_service", getter)
svc = PlaybookService(db_session)
pb = await svc.draft(_create(title="Do not index"), created_by=uuid4())
await svc.approve(pb.id, approver_id=uuid4())
getter.assert_not_awaited()
@pytest.mark.asyncio
async def test_approve_survives_index_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "org_memory_enabled", True)
fake_optimal = AsyncMock()
fake_optimal.index_playbook = AsyncMock(side_effect=RuntimeError("ollama down"))
monkeypatch.setattr(
"roboco.services.optimal.get_optimal_service",
AsyncMock(return_value=fake_optimal),
)
svc = PlaybookService(db_session)
pb = await svc.draft(_create(title="Resilient"), created_by=uuid4())
approved = await svc.approve(pb.id, approver_id=uuid4()) # must not raise
assert approved.status == PlaybookStatus.APPROVED
+235
View File
@@ -0,0 +1,235 @@
"""Release-manager route coverage — CEO-only GET / approve / reject."""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.release import router as release_router
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import TaskNature, TaskStatus, TaskType
from roboco.models.permissions import AgentContext
from roboco.services.release_executor import ReleaseResult
from roboco.services.release_readiness import ReleaseReadinessReport, report_to_dict
from roboco.services.task import RELEASE_MANAGER_SOURCE
from sqlalchemy import delete
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
_VERSION = "0.13.0"
def _report() -> ReleaseReadinessReport:
return ReleaseReadinessReport(
proposed_version=_VERSION,
bump_kind="minor",
change_summary=["feat: a thing"],
drafted_changelog=f"## [{_VERSION}] - 2026-06-25\n\n### Added\n- a thing\n",
version_bump_plan=["pyproject.toml"],
gaps=[],
migration_notes=[],
gate_state="green",
)
async def _seed_agent(session: AsyncSession, role: AgentRole, slug: str) -> AgentTable:
agent = AgentTable(
id=uuid4(),
name=slug,
slug=f"{slug}-{uuid4().hex[:6]}",
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
session.add(agent)
await session.flush()
return agent
async def _seed_proposal(session: AsyncSession) -> TaskTable:
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
secretary = await _seed_agent(session, AgentRole.SECRETARY, "secretary")
project = ProjectTable(
id=uuid4(),
name="RoboCo",
slug=f"roboco-{uuid4().hex[:6]}",
git_url="https://example.com/roboco.git",
assigned_cell=Team.BACKEND,
created_by=system.id,
)
session.add(project)
await session.flush()
task = TaskTable(
id=uuid4(),
title=f"Release proposal: v{_VERSION}",
description="proposal body",
acceptance_criteria=["CEO approves"],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.ADMINISTRATIVE,
nature=TaskNature.NON_TECHNICAL,
project_id=project.id,
created_by=system.id,
assigned_to=secretary.id,
team=Team.MAIN_PM,
source=RELEASE_MANAGER_SOURCE,
confirmed_by_human=False,
orchestration_markers={"release_report": report_to_dict(_report())},
)
session.add(task)
await session.flush()
return task
def _build_app(db_session: AsyncSession, role: AgentRole, agent_id: UUID) -> FastAPI:
app = FastAPI()
app.include_router(release_router, prefix="/api/release")
async def _override_db() -> AsyncIterator[AsyncSession]:
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(agent_id=agent_id, role=role, team=None)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
return app
@pytest_asyncio.fixture
async def ceo_client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
app = _build_app(db_session, AgentRole.CEO, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
# The approve/reject routes call db.commit() (real behavior), so a held
# proposal persists past the per-test rollback. list_open_release_proposals()
# is global (source-scoped, all projects), so clean up to avoid leaking into
# the engine tests that assert on it.
await db_session.execute(
delete(TaskTable).where(TaskTable.source == RELEASE_MANAGER_SOURCE)
)
await db_session.commit()
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_get_proposal_returns_open_proposal(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
await _seed_proposal(db_session)
resp = await ceo_client.get("/api/release/proposal")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["report"]["proposed_version"] == _VERSION
assert body["report"]["bump_kind"] == "minor"
@pytest.mark.asyncio
async def test_get_proposal_404_when_none(ceo_client: AsyncClient) -> None:
resp = await ceo_client.get("/api/release/proposal")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_approve_runs_executor_and_completes(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_proposal(db_session)
published = ReleaseResult(
status="published",
version=_VERSION,
files_changed=["pyproject.toml"],
commit_sha="abc123",
release_url=f"https://github.com/x/roboco/releases/tag/v{_VERSION}",
detail="ok",
)
fake_executor = AsyncMock()
fake_executor.execute = AsyncMock(return_value=published)
with patch(
"roboco.services.release_proposal.get_release_executor",
AsyncMock(return_value=fake_executor),
):
resp = await ceo_client.post("/api/release/proposal/approve")
assert resp.status_code == HTTPStatus.OK
assert resp.json()["status"] == "published"
fake_executor.execute.assert_awaited_once()
refreshed = await db_session.get(TaskTable, task.id)
assert refreshed is not None
assert refreshed.status == TaskStatus.COMPLETED
@pytest.mark.asyncio
async def test_approve_gate_failure_keeps_proposal_open(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_proposal(db_session)
failed = ReleaseResult(
status="gate_failed",
version=_VERSION,
files_changed=["pyproject.toml"],
commit_sha=None,
release_url=None,
detail="make quality failed",
)
fake_executor = AsyncMock()
fake_executor.execute = AsyncMock(return_value=failed)
with patch(
"roboco.services.release_proposal.get_release_executor",
AsyncMock(return_value=fake_executor),
):
resp = await ceo_client.post("/api/release/proposal/approve")
assert resp.status_code == HTTPStatus.OK
assert resp.json()["status"] == "gate_failed"
refreshed = await db_session.get(TaskTable, task.id)
assert refreshed is not None
assert refreshed.status == TaskStatus.PENDING # still held
@pytest.mark.asyncio
async def test_reject_records_changes_and_keeps_open(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_proposal(db_session)
resp = await ceo_client.post(
"/api/release/proposal/reject",
json={"required_changes": "Tighten the CHANGELOG wording for the API change."},
)
assert resp.status_code == HTTPStatus.OK
assert "Tighten the CHANGELOG" in (resp.json()["required_changes"] or "")
refreshed = await db_session.get(TaskTable, task.id)
assert refreshed is not None
assert refreshed.status == TaskStatus.PENDING # stays held for revision
@pytest.mark.asyncio
async def test_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
await _seed_proposal(db_session)
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
get_resp = await client.get("/api/release/proposal")
approve_resp = await client.post("/api/release/proposal/approve")
reject_resp = await client.post(
"/api/release/proposal/reject", json={"required_changes": "x" * 20}
)
assert get_resp.status_code == HTTPStatus.FORBIDDEN
assert approve_resp.status_code == HTTPStatus.FORBIDDEN
assert reject_resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
+38
View File
@@ -0,0 +1,38 @@
"""The org-memory loop is gated by default-off config flags (mirrors self-heal)."""
from __future__ import annotations
import os
from unittest import mock
from roboco.config import Settings
from roboco.services.settings import FEATURE_FLAGS, validate_setting
_DEFAULT_TOP_K = 3
_DEFAULT_MIN_SCORE = 0.6
def test_org_memory_disabled_by_default() -> None:
s = Settings()
assert s.org_memory_enabled is False
assert s.org_memory_top_k == _DEFAULT_TOP_K
assert s.org_memory_min_score == _DEFAULT_MIN_SCORE
def test_org_memory_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_ORG_MEMORY_ENABLED": "true"}):
assert Settings().org_memory_enabled is True
def test_org_memory_top_k_reads_env_var() -> None:
override = 5
with mock.patch.dict(os.environ, {"ROBOCO_ORG_MEMORY_TOP_K": str(override)}):
assert Settings().org_memory_top_k == override
def test_org_memory_flag_registered_in_feature_flags() -> None:
assert "org_memory_enabled" in [key for key, _ in FEATURE_FLAGS]
def test_org_memory_flag_validates_as_bool() -> None:
validate_setting("org_memory_enabled", "true")
@@ -0,0 +1,38 @@
"""Gated release manager is gated by default-off config flags (mirrors self-heal)."""
from __future__ import annotations
import os
from unittest import mock
from roboco.config import Settings
from roboco.services.settings import FEATURE_FLAGS, validate_setting
_DEFAULT_MIN_COMMITS = 8
_DEFAULT_INTERVAL = 3600
def test_release_manager_disabled_by_default() -> None:
s = Settings()
assert s.release_manager_enabled is False
assert s.release_min_commits == _DEFAULT_MIN_COMMITS
assert s.release_manager_interval_seconds == _DEFAULT_INTERVAL
def test_release_manager_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_RELEASE_MANAGER_ENABLED": "true"}):
assert Settings().release_manager_enabled is True
def test_release_min_commits_reads_env_var() -> None:
override = 12
with mock.patch.dict(os.environ, {"ROBOCO_RELEASE_MIN_COMMITS": str(override)}):
assert Settings().release_min_commits == override
def test_release_manager_flag_registered_in_feature_flags() -> None:
assert "release_manager_enabled" in [key for key, _ in FEATURE_FLAGS]
def test_release_manager_flag_validates_as_bool() -> None:
validate_setting("release_manager_enabled", "true")
@@ -0,0 +1,91 @@
"""Org-memory keystone — role-shaped query + relevance-floored injection.
``shape_memory_query`` shapes the KB query per role; ``EvidenceRepo.similar_memory``
applies the cosine floor + top-K and returns the shaped items that
``_briefing_for`` injects as ``context_briefing["institutional_memory"]`` (only
when ``org_memory_enabled``).
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.models.optimal import IndexType, SearchResult
from roboco.services.gateway.evidence_builder import shape_memory_query
from roboco.services.gateway.evidence_repo import EvidenceRepo
_FLOOR = 0.6
_HIGH = 0.9
_LOW = 0.4
def _result(score: float, index_type: IndexType = IndexType.LEARNINGS) -> SearchResult:
return SearchResult(
content="a distilled lesson body",
source="roboco://learnings/lrn-1",
score=score,
index_type=index_type,
)
def test_shape_memory_query_is_role_specific() -> None:
dev = shape_memory_query("developer", "Add retry", "code")
pm = shape_memory_query("cell_pm", "Add retry", "code")
qa = shape_memory_query("qa", "Add retry", "code")
doc = shape_memory_query("documenter", "Add retry", "documentation")
assert dev != pm # role shaping actually differs
assert "Add retry" in dev and "implementation" in dev
assert "decomposition" in pm
assert "defect" in qa
assert "documentation pattern" in doc
@pytest.mark.asyncio
async def test_similar_memory_applies_floor_and_shapes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
optimal = MagicMock()
optimal.search = AsyncMock(
return_value=[_result(_HIGH, IndexType.PLAYBOOKS), _result(_LOW)]
)
monkeypatch.setattr(
"roboco.services.optimal.get_optimal_service",
AsyncMock(return_value=optimal),
)
items = await EvidenceRepo(MagicMock()).similar_memory(
query="q", top_k=3, min_score=_FLOOR
)
assert len(items) == 1 # the 0.4 result is below the floor, excluded
assert items[0]["kind"] == "playbook"
assert items[0]["score"] == _HIGH
@pytest.mark.asyncio
async def test_similar_memory_caps_at_top_k(
monkeypatch: pytest.MonkeyPatch,
) -> None:
optimal = MagicMock()
optimal.search = AsyncMock(return_value=[_result(_HIGH) for _ in range(5)])
monkeypatch.setattr(
"roboco.services.optimal.get_optimal_service",
AsyncMock(return_value=optimal),
)
items = await EvidenceRepo(MagicMock()).similar_memory(
query="q", top_k=2, min_score=_FLOOR
)
assert len(items) == 2 # noqa: PLR2004 - top_k cap
@pytest.mark.asyncio
async def test_similar_memory_empty_on_rag_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"roboco.services.optimal.get_optimal_service",
AsyncMock(side_effect=RuntimeError("rag down")),
)
items = await EvidenceRepo(MagicMock()).similar_memory(
query="q", top_k=3, min_score=_FLOOR
)
assert items == []
+133
View File
@@ -0,0 +1,133 @@
"""Playbook content verbs — role grants + ContentActions RBAC.
Delivery roles DRAFT playbooks; only the Auditor CURATES (approve/reject/archive).
The Auditor's no-say/no-dm restriction is preserved (these are KB curation
actions, not agent comms).
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
from roboco.services.gateway.role_config import get_role_config
_DRAFT_ROLES = ("developer", "qa", "documenter", "cell_pm", "main_pm")
_CURATE_VERBS = ("approve_playbook", "reject_playbook", "archive_playbook")
# --- role grants (spawn-manifest source of truth) --------------------------- #
def test_delivery_roles_can_draft_playbook() -> None:
for role in _DRAFT_ROLES:
assert "draft_playbook" in get_role_config(role).do_tools
def test_auditor_curates_but_does_not_draft() -> None:
do_tools = get_role_config("auditor").do_tools
for verb in _CURATE_VERBS:
assert verb in do_tools
assert "draft_playbook" not in do_tools
# No-say/no-dm preserved.
assert "say" not in do_tools
assert "dm" not in do_tools
def test_delivery_role_cannot_curate() -> None:
assert "approve_playbook" not in get_role_config("developer").do_tools
# --- ContentActions RBAC ---------------------------------------------------- #
def _actions(role: str) -> ContentActions:
task = MagicMock()
agent = MagicMock()
agent.role = role
task.agent_for = AsyncMock(return_value=agent)
task.session = MagicMock()
deps = ContentActionsDeps(
task=task,
git=MagicMock(),
messaging=MagicMock(),
a2a=MagicMock(),
journal=MagicMock(),
workspace=MagicMock(),
notifications=MagicMock(),
)
return ContentActions(deps)
@pytest.mark.asyncio
async def test_draft_playbook_forbidden_for_auditor() -> None:
env = await _actions("auditor").draft_playbook(
agent_id=uuid4(),
title="Retry flaky pg",
problem="connection resets",
procedure="1. retry with backoff",
)
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_draft_playbook_creates_for_developer(
monkeypatch: pytest.MonkeyPatch,
) -> None:
created = MagicMock()
created.id = uuid4()
svc = MagicMock()
svc.draft = AsyncMock(return_value=created)
monkeypatch.setattr("roboco.services.playbook.get_playbook_service", lambda _s: svc)
env = await _actions("developer").draft_playbook(
agent_id=uuid4(),
title="Retry flaky pg",
problem="connection resets",
procedure="1. retry with backoff",
)
assert env.error is None
assert env.status == "playbook_drafted"
svc.draft.assert_awaited_once()
@pytest.mark.asyncio
async def test_approve_playbook_forbidden_for_developer() -> None:
env = await _actions("developer").approve_playbook(
agent_id=uuid4(), playbook_id=uuid4()
)
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_approve_playbook_for_auditor(monkeypatch: pytest.MonkeyPatch) -> None:
approved = MagicMock()
approved.id = uuid4()
approved.status = "approved"
svc = MagicMock()
svc.approve = AsyncMock(return_value=approved)
monkeypatch.setattr("roboco.services.playbook.get_playbook_service", lambda _s: svc)
env = await _actions("auditor").approve_playbook(
agent_id=uuid4(), playbook_id=uuid4()
)
assert env.error is None
assert env.status == "playbook_approved"
svc.approve.assert_awaited_once()
@pytest.mark.asyncio
async def test_reject_playbook_archives_for_auditor(
monkeypatch: pytest.MonkeyPatch,
) -> None:
archived = MagicMock()
archived.id = uuid4()
archived.status = "archived"
svc = MagicMock()
svc.reject = AsyncMock(return_value=archived)
monkeypatch.setattr("roboco.services.playbook.get_playbook_service", lambda _s: svc)
env = await _actions("auditor").reject_playbook(
agent_id=uuid4(), playbook_id=uuid4(), reason="duplicate"
)
assert env.status == "playbook_archived"
svc.reject.assert_awaited_once()
+1 -1
View File
@@ -44,7 +44,7 @@ async def test_load_watch_set_filters_enabled_one_per_repo() -> None:
assert watch[0].git_url == "https://x/a.git"
def _db_ctx(db: Any):
def _db_ctx(db: Any) -> Any:
@asynccontextmanager
async def _ctx() -> Any:
yield db
+1 -1
View File
@@ -44,7 +44,7 @@ async def test_load_set_filters_command_one_per_repo() -> None:
assert eligible[0].git_url == "https://x/a.git"
def _db_ctx(db: Any):
def _db_ctx(db: Any) -> Any:
@asynccontextmanager
async def _ctx() -> Any:
yield db
@@ -41,6 +41,7 @@ async def test_prunes_dangling_when_enabled_and_due(
with patch("roboco.runtime.orchestrator.asyncio.create_subprocess_exec", spawn):
await orch._sweep_dangling_images()
spawn.assert_awaited_once()
assert spawn.await_args is not None
args: tuple[Any, ...] = spawn.await_args.args
assert args[:6] == (
"docker",
@@ -0,0 +1,27 @@
"""The release-manager orchestrator loop is fully dormant when disabled (default).
With ``release_manager_enabled`` off, ``_release_manager_loop`` must return
immediately no sleep, no clone, no DB so a standard deployment behaves
exactly as today.
"""
from __future__ import annotations
import asyncio
import types
from typing import cast
import pytest
from roboco.config import settings as cfg
from roboco.runtime.orchestrator import AgentOrchestrator
@pytest.mark.asyncio
async def test_release_manager_loop_returns_immediately_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "release_manager_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
# Gated off → returns at once. If the gate were missing it would sleep the
# full interval and this wait_for would time out.
await asyncio.wait_for(AgentOrchestrator._release_manager_loop(stub), timeout=1.0)
@@ -0,0 +1,40 @@
"""PlaybooksIndexPlugin — index_type + pure metadata/URI methods.
Mirrors the other index-plugin unit tests (instantiate via __new__, exercise the
pure methods). The embed + pgvector ingest/search path is inherited from
BaseIndexPlugin (shared, proven by the other 8 plugins) and runs live.
"""
from __future__ import annotations
from roboco.models.optimal import IndexType
from roboco.services.optimal_brain.indexes.playbooks import PlaybooksIndexPlugin
def _plugin() -> PlaybooksIndexPlugin:
return PlaybooksIndexPlugin.__new__(PlaybooksIndexPlugin)
def test_index_type_is_playbooks() -> None:
assert _plugin().index_type == IndexType.PLAYBOOKS
def test_prepare_metadata_marks_approved_with_routing_fields() -> None:
md = _plugin().prepare_metadata(
"content", playbook_id="pb-1", team="backend", scope="org", tags=["retry"]
)
assert md["type"] == "playbook"
assert md["playbook_id"] == "pb-1"
assert md["team"] == "backend"
assert md["scope"] == "org"
assert md["tags"] == ["retry"]
# Only approved playbooks are ever indexed — the metadata says so.
assert md["status"] == "approved"
def test_build_source_uri_with_id() -> None:
assert _plugin().build_source_uri(doc_id="pb-1") == "roboco://playbooks/pb-1"
def test_build_source_uri_none_when_missing() -> None:
assert _plugin().build_source_uri(doc_id=None) is None
@@ -0,0 +1,59 @@
"""MemoryDistiller — a local-LLM distilled completion lesson (best-effort)."""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from roboco.services.memory_distiller import LessonInput, MemoryDistiller
def _input() -> LessonInput:
return LessonInput(
title="Add a retry to the flaky pg fixture",
acceptance_criteria=["The pg test passes 100 times in a row"],
dev_notes="Wrapped the connect in a 3x retry with backoff.",
qa_notes="Confirmed stable across 200 runs.",
commit_messages=["fix: retry pg connect", "test: stress the fixture"],
)
@pytest.mark.asyncio
async def test_distill_returns_lesson(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"roboco.services.memory_distiller._chat",
AsyncMock(
return_value="Problem: flaky pg. Approach: retry+backoff. Gotcha: reset."
),
)
out = await MemoryDistiller().distill(_input())
assert out is not None
assert "Gotcha" in out
@pytest.mark.asyncio
async def test_distill_none_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"roboco.services.memory_distiller._chat", AsyncMock(side_effect=RuntimeError)
)
assert await MemoryDistiller().distill(_input()) is None
@pytest.mark.asyncio
async def test_distill_none_on_empty_response(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"roboco.services.memory_distiller._chat", AsyncMock(return_value=" ")
)
assert await MemoryDistiller().distill(_input()) is None
@pytest.mark.asyncio
async def test_distill_caps_at_120_words(monkeypatch: pytest.MonkeyPatch) -> None:
long_lesson = " ".join(f"word{i}" for i in range(300))
monkeypatch.setattr(
"roboco.services.memory_distiller._chat",
AsyncMock(return_value=long_lesson),
)
out = await MemoryDistiller().distill(_input())
assert out is not None
assert len(out.split()) <= 120 # noqa: PLR2004 - the documented word budget
@@ -32,7 +32,7 @@ def _ci(conclusion: str) -> dict[str, Any]:
@pytest.mark.asyncio
async def test_fanout_red_green_and_none() -> None:
projects = [_project("red"), _project("green"), _project("nosig")]
projects: list[object] = [_project("red"), _project("green"), _project("nosig")]
async def conclusion(slug: str, **_kwargs: Any) -> Any:
return {"red": _ci("failure"), "green": _ci("success"), "nosig": None}[slug]
@@ -50,7 +50,7 @@ async def test_fanout_red_green_and_none() -> None:
@pytest.mark.asyncio
async def test_per_project_error_isolated() -> None:
projects = [_project("boom"), _project("ok")]
projects: list[object] = [_project("boom"), _project("ok")]
async def conclusion(slug: str, **_kwargs: Any) -> Any:
if slug == "boom":
@@ -72,7 +72,10 @@ async def test_per_project_workflow_passthrough(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "ci_watch_default_workflow", "ci.yml")
projects = [_project("custom", workflow="release.yml"), _project("default")]
projects: list[object] = [
_project("custom", workflow="release.yml"),
_project("default"),
]
git = MagicMock()
git.get_latest_ci_conclusion = AsyncMock(return_value=_ci("success"))
with patch("roboco.services.telemetry.source.GitService", return_value=git):
+51
View File
@@ -76,12 +76,17 @@ def _patch_topology(monkeypatch: pytest.MonkeyPatch) -> dict[str, MagicMock]:
proj.id = uuid4()
project_svc = MagicMock()
project_svc.create = AsyncMock(return_value=proj)
# Default: nothing pre-exists, so provisioning takes the create path. The
# idempotency tests override these to return an existing row.
project_svc.get_by_slug = AsyncMock(return_value=None)
monkeypatch.setattr(pitch_module, "get_project_service", lambda _s: project_svc)
prod = MagicMock()
prod.id = uuid4()
product_svc = MagicMock()
product_svc.create = AsyncMock(return_value=prod)
product_svc.get_by_slug = AsyncMock(return_value=None)
product_svc.update = AsyncMock(return_value=prod)
monkeypatch.setattr(pitch_module, "get_product_service", lambda _s: product_svc)
task = MagicMock()
@@ -215,3 +220,49 @@ async def test_approve_blocked_when_provisioning_disabled(
await svc.approve(
uuid4(), "x", uuid4(), provisioning=_FakeProvisioning(enabled=False)
)
@pytest.mark.asyncio
async def test_approve_reuses_existing_project(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Re-approval after a partial provision reuses a committed Project by slug —
no repo re-create, no slug collision (idempotent provisioning)."""
svcs = _patch_topology(monkeypatch)
existing = MagicMock()
existing.id = uuid4()
svcs["project"].get_by_slug = AsyncMock(return_value=existing)
svc = PitchService(_session())
monkeypatch.setattr(
svc, "get", AsyncMock(return_value=_pitch(target_cells=["backend"]))
)
prov = _FakeProvisioning()
await svc.approve(
uuid4(), "ship it, aligned with the charter", uuid4(), provisioning=prov
)
assert prov.created == [] # repo NOT re-created
svcs["project"].create.assert_not_awaited() # Project reused, not re-created
@pytest.mark.asyncio
async def test_approve_reuses_existing_product(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Multi-cell re-approval reuses the committed Product and refreshes its cell
map (no uq_product_projects_product_team collision)."""
svcs = _patch_topology(monkeypatch)
existing = MagicMock()
existing.id = uuid4()
svcs["product"].get_by_slug = AsyncMock(return_value=existing)
svc = PitchService(_session())
monkeypatch.setattr(
svc, "get", AsyncMock(return_value=_pitch(target_cells=["backend", "frontend"]))
)
await svc.approve(
uuid4(),
"ship it, aligned with the charter",
uuid4(),
provisioning=_FakeProvisioning(),
)
svcs["product"].update.assert_awaited_once() # reused + cell map refreshed
svcs["product"].create.assert_not_awaited() # NOT re-created
@@ -0,0 +1,143 @@
"""ReleaseExecutor: fail-closed bump → gate → commit → CI → publish (post-approval).
The executor's correctness is its ORDERING + fail-closed aborts: a red gate
aborts before any commit, a red release-commit CI aborts before publish, and a
green path publishes exactly once. Tested against a fake ops that records the
call sequence; the production git/gh ops is exercised live (CEO-gated).
"""
from __future__ import annotations
import pytest
from roboco.services.release_executor import ReleaseExecutor, ReleaseResult
from roboco.services.release_readiness import ReleaseReadinessReport
_PLAN = ["pyproject.toml", "roboco/__init__.py", "CHANGELOG.md"]
_VERSION = "0.13.0"
_ONE = 1
def _report() -> ReleaseReadinessReport:
return ReleaseReadinessReport(
proposed_version=_VERSION,
bump_kind="minor",
change_summary=["feat: a thing"],
drafted_changelog=(
f"## [{_VERSION}] - 2026-06-25\n\n### Added\n- a thing (#1)\n"
),
version_bump_plan=list(_PLAN),
gaps=[],
migration_notes=[],
gate_state="green",
)
class _FakeOps:
"""Records the call sequence; flags drive gate/CI/already-published outcomes."""
def __init__(self, *, already: bool = False, gate: bool = True, ci: bool = True):
self._already = already
self._gate = gate
self._ci = ci
self.calls: list[str] = []
self.bumped_plan: list[str] | None = None
self.bumped_version: str | None = None
async def is_already_published(self, _version: str) -> bool:
self.calls.append("check")
return self._already
async def apply_version_bumps(self, plan: list[str], new_version: str) -> list[str]:
self.calls.append("bump")
self.bumped_plan = list(plan)
self.bumped_version = new_version
return list(plan)
async def write_changelog_entry(self, _entry: str) -> None:
self.calls.append("changelog")
async def run_gate(self) -> bool:
self.calls.append("gate")
return self._gate
async def commit_and_push(self, _version: str) -> str:
self.calls.append("commit")
return "deadbeef"
async def wait_for_ci(self, _commit_sha: str) -> bool:
self.calls.append("ci")
return self._ci
async def publish_release(self, version: str, _notes: str) -> str:
self.calls.append("publish")
return f"https://github.com/x/roboco/releases/tag/v{version}"
@pytest.mark.asyncio
async def test_green_path_publishes_once() -> None:
ops = _FakeOps()
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "published"
assert result.release_url is not None
assert result.commit_sha == "deadbeef"
assert ops.calls.count("publish") == _ONE
assert ops.calls == [
"check",
"bump",
"changelog",
"gate",
"commit",
"ci",
"publish",
]
@pytest.mark.asyncio
async def test_bump_targets_the_canonical_set() -> None:
ops = _FakeOps()
result = await ReleaseExecutor(ops).execute(_report())
assert ops.bumped_plan == _PLAN
assert ops.bumped_version == _VERSION
assert result.files_changed == _PLAN
@pytest.mark.asyncio
async def test_red_gate_aborts_before_commit() -> None:
ops = _FakeOps(gate=False)
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "gate_failed"
assert "commit" not in ops.calls
assert "publish" not in ops.calls
@pytest.mark.asyncio
async def test_red_ci_aborts_before_publish() -> None:
ops = _FakeOps(ci=False)
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "ci_failed"
assert "commit" in ops.calls
assert "publish" not in ops.calls
@pytest.mark.asyncio
async def test_already_published_is_a_noop() -> None:
ops = _FakeOps(already=True)
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "already_published"
assert "bump" not in ops.calls
assert "commit" not in ops.calls
assert "publish" not in ops.calls
def test_release_result_carries_outcome_fields() -> None:
result = ReleaseResult(
status="published",
version=_VERSION,
files_changed=list(_PLAN),
commit_sha="abc",
release_url="https://example/releases/v0.13.0",
detail="ok",
)
assert result.version == _VERSION
assert result.files_changed == _PLAN
assert result.release_url is not None
@@ -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() == []
@@ -0,0 +1,90 @@
"""Pure release-readiness primitives: classify changes + derive semver bump.
These are git-free so they're unit-testable from synthetic commits. The
git-backed assess() is covered in test_release_readiness_audit.py (Task 3).
"""
from __future__ import annotations
from roboco.services.release_readiness import (
CommitInfo,
classify_changes,
derive_bump,
next_version,
)
def _commit(subject: str, body: str = "", labels: tuple[str, ...] = ()) -> CommitInfo:
return CommitInfo(sha="abc1234", subject=subject, body=body, labels=labels)
def test_feat_drives_minor_bump() -> None:
changes = classify_changes([_commit("feat: add X"), _commit("fix: a bug")])
assert derive_bump(changes) == "minor"
def test_only_fix_and_chore_is_patch() -> None:
changes = classify_changes([_commit("fix: a bug"), _commit("chore: bump deps")])
assert derive_bump(changes) == "patch"
def test_bang_marker_drives_major() -> None:
changes = classify_changes([_commit("feat!: drop the old API")])
assert derive_bump(changes) == "major"
def test_breaking_change_body_drives_major() -> None:
changes = classify_changes(
[_commit("feat: new thing", body="BREAKING CHANGE: removes Y")]
)
assert derive_bump(changes) == "major"
def test_security_change_is_patch_when_not_breaking() -> None:
changes = classify_changes([_commit("security: patch a CVE")])
assert derive_bump(changes) == "patch"
def test_empty_change_set_is_patch() -> None:
assert derive_bump([]) == "patch"
def test_next_version_minor() -> None:
assert next_version("0.8.0", "minor") == "0.9.0"
def test_next_version_patch() -> None:
assert next_version("0.8.0", "patch") == "0.8.1"
def test_next_version_major() -> None:
assert next_version("0.8.0", "major") == "1.0.0"
def test_next_version_tolerates_v_prefix() -> None:
assert next_version("v0.12.0", "minor") == "0.13.0"
def test_classify_extracts_kind_and_summary() -> None:
[change] = classify_changes([_commit("feat(api): add endpoint (#12)")])
assert change.kind == "feat"
assert change.breaking is False
assert change.summary == "add endpoint (#12)"
assert change.needs_manual_classification is False
def test_unknown_subject_flags_manual_classification() -> None:
[change] = classify_changes([_commit("Random merge subject")])
assert change.kind == "other"
assert change.needs_manual_classification is True
def test_pr_label_fallback_classifies_unconventional_subject() -> None:
[change] = classify_changes([_commit("Random subject", labels=("bug",))])
assert change.kind == "fix"
assert change.needs_manual_classification is False
def test_breaking_label_drives_major_even_on_unconventional_subject() -> None:
changes = classify_changes([_commit("Big rework", labels=("breaking",))])
assert derive_bump(changes) == "major"
@@ -0,0 +1,176 @@
"""The readiness audit: assess() turns a repo snapshot into a gap-flagged report.
assess() is pure over a ``ReleaseRepoSnapshot`` so every "no stone unturned"
check (changelog/version-ref/docs-drift/migration/gate completeness) is tested
from synthetic data. The git-backed gather_snapshot() is smoke-tested against
the real repo at the bottom.
"""
from __future__ import annotations
import re
from dataclasses import replace
from pathlib import Path
from typing import Any
from roboco.services.release_readiness import (
CommitInfo,
ReleaseReadinessReport,
ReleaseRepoSnapshot,
assess,
gather_snapshot,
)
_TODAY = "2026-06-25"
_DECLARED = 25
_DRIFTED = 26
def _snap(**overrides: Any) -> ReleaseRepoSnapshot:
base = ReleaseRepoSnapshot(
current_version="0.12.0",
last_tag="v0.12.0",
commits=[CommitInfo(sha="a1", subject="feat: add a thing", pr_number=1)],
tracked_files_with_version=["pyproject.toml"],
canonical_bump_files=["pyproject.toml"],
changelog_text="## [Unreleased]\n### Added\n- add a thing (#1)\n",
new_migrations=[],
migration_head_count=1,
master_ci_conclusion="success",
declared_agent_count=_DECLARED,
actual_agent_count=_DECLARED,
verb_tables_stale=False,
)
return replace(base, **overrides)
def _categories(report: ReleaseReadinessReport) -> set[str]:
return {gap.category for gap in report.gaps}
def test_assess_proposes_next_version_and_bump() -> None:
report = assess(_snap(), today=_TODAY)
assert report.bump_kind == "minor"
assert report.proposed_version == "0.13.0"
assert report.gate_state == "green"
def test_clean_snapshot_has_no_gaps() -> None:
assert assess(_snap(), today=_TODAY).gaps == []
def test_undocumented_commit_is_a_changelog_gap() -> None:
snap = _snap(
commits=[CommitInfo(sha="a1", subject="feat: undocumented", pr_number=99)],
changelog_text="## [Unreleased]\n",
)
assert "changelog" in _categories(assess(snap, today=_TODAY))
def test_chore_commit_does_not_need_a_changelog_line() -> None:
snap = _snap(
commits=[CommitInfo(sha="a1", subject="chore: tidy imports", pr_number=7)],
changelog_text="## [Unreleased]\n",
)
assert "changelog" not in _categories(assess(snap, today=_TODAY))
def test_missed_version_ref_is_a_gap() -> None:
snap = _snap(
tracked_files_with_version=["pyproject.toml", "panel/pnpm-lock.yaml"],
canonical_bump_files=["pyproject.toml"],
)
report = assess(snap, today=_TODAY)
version_gaps = [g for g in report.gaps if g.category == "version_ref"]
assert any("pnpm-lock.yaml" in g.detail for g in version_gaps)
def test_bump_plan_is_the_canonical_set() -> None:
snap = _snap(canonical_bump_files=["pyproject.toml", "roboco/__init__.py"])
report = assess(snap, today=_TODAY)
assert report.version_bump_plan == ["pyproject.toml", "roboco/__init__.py"]
def test_stale_agent_count_is_docs_drift_gap() -> None:
snap = _snap(declared_agent_count=_DECLARED, actual_agent_count=_DRIFTED)
assert "docs_drift" in _categories(assess(snap, today=_TODAY))
def test_stale_verb_tables_is_docs_drift_gap() -> None:
assert "docs_drift" in _categories(
assess(_snap(verb_tables_stale=True), today=_TODAY)
)
def test_new_migration_listed_in_notes() -> None:
snap = _snap(new_migrations=["alembic/versions/050_playbooks.py"])
report = assess(snap, today=_TODAY)
assert any("050_playbooks" in note for note in report.migration_notes)
def test_multiple_alembic_heads_is_a_migration_gap() -> None:
head_count = 2
assert "migration" in _categories(
assess(_snap(migration_head_count=head_count), today=_TODAY)
)
def test_red_ci_is_a_gate_gap() -> None:
report = assess(_snap(master_ci_conclusion="failure"), today=_TODAY)
assert report.gate_state == "red"
assert "gate" in _categories(report)
def test_unknown_ci_is_a_gate_gap() -> None:
report = assess(_snap(master_ci_conclusion=None), today=_TODAY)
assert report.gate_state == "unknown"
assert "gate" in _categories(report)
def test_unclassifiable_commit_is_a_classification_gap() -> None:
snap = _snap(
commits=[CommitInfo(sha="a1", subject="random merge subject", pr_number=5)],
changelog_text="- random merge subject (#5)\n",
)
assert "classification" in _categories(assess(snap, today=_TODAY))
def test_drafted_changelog_is_keepachangelog_and_single_line() -> None:
snap = _snap(
commits=[
CommitInfo(sha="a1", subject="feat: add A", pr_number=1),
CommitInfo(sha="b2", subject="fix: fix B", pr_number=2),
],
changelog_text="- add A (#1)\n- fix B (#2)\n",
)
report = assess(snap, today=_TODAY)
assert "## [0.13.0] - 2026-06-25" in report.drafted_changelog
assert "### Added" in report.drafted_changelog
assert "### Fixed" in report.drafted_changelog
bullets = [
ln for ln in report.drafted_changelog.splitlines() if ln.startswith("- ")
]
assert len(bullets) == 2 # noqa: PLR2004 - exactly the two commits above
# --- gather_snapshot: real-repo smoke (this repo is a git checkout at 0.12.0) ---
def test_gather_snapshot_reads_the_real_repo() -> None:
root = Path(__file__).resolve().parents[3]
snap = gather_snapshot(root, master_ci_conclusion=None)
# The repo version moves with each release — assert it's a semver, not a literal.
assert re.fullmatch(r"\d+\.\d+\.\d+", snap.current_version)
assert snap.last_tag is not None
assert isinstance(snap.commits, list)
assert "pyproject.toml" in snap.canonical_bump_files
assert snap.changelog_text # CHANGELOG.md is non-empty
assert snap.migration_head_count >= 1
def test_gather_snapshot_then_assess_produces_a_report() -> None:
root = Path(__file__).resolve().parents[3]
report = assess(gather_snapshot(root, master_ci_conclusion="success"), today=_TODAY)
assert report.proposed_version
assert report.bump_kind in {"major", "minor", "patch"}
assert report.gate_state == "green"