mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 == []
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user