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()
|
||||
Reference in New Issue
Block a user