Feat/v0.13.0 (#270)

* feat(release): add release-manager feature flag (default off)

* feat(release): change classification + semver-bump derivation

* feat(release): readiness audit (changelog/version-ref/docs/migration/gate)

* feat(release): release-manager engine proposes a gated release

* feat(release): fail-closed release executor (bump, gate, publish)

* feat(release): CEO approve/reject release-proposal surface

* docs(release): document the gated release manager

* feat(memory): add org-memory feature flags (default off)

* feat(memory): add playbooks table + status enum + migration

* feat(memory): playbook service with auditor curation transitions

* feat(memory): playbooks RAG index plugin

* feat(memory): index a playbook into RAG on approval

* feat(memory): distill a high-signal lesson at task completion

* feat(memory): keep private journal reflections out of the shared RAG corpus

* feat(memory): draft_playbook verb + auditor curation verbs

* fix(ci): resolve mypy tests/ errors blocking the gate (UUID casts, annotations)

* feat(memory): auto-inject similar lessons/playbooks into the briefing

* feat(memory): auditor playbook review queue (api + panel)

* docs(memory): document the org-memory loop + playbook verbs

* fix(provisioning): idempotent pitch provisioning (reuse product/project by slug on re-approval)

* fix(memory): add chunks_playbooks to the chunk schema + isolate release route tests

- Migration 030's CHUNK_TABLES was missing chunks_playbooks, breaking the
  IndexType<->migration parity guard once the PLAYBOOKS index landed. The
  upgrade is ALTER ... IF EXISTS so adding it is safe on any DB shape.
- The release-route fixture's approve/reject paths call db.commit() (real
  behavior), so a held proposal outlived the per-test rollback and leaked
  into engine tests that read the global list_open_release_proposals().
  Tear down source=release_manager rows after each test.
- Make the gather_snapshot real-repo smoke version-agnostic (semver match)
  so it stops pinning the literal repo version.

* chore(release): 0.13.0

* ++

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-26 01:43:08 +02:00
committed by GitHub
co-authored by Renn F
parent 153723406e
commit 5612375cba
87 changed files with 5032 additions and 91 deletions
@@ -8,8 +8,8 @@ gets at most one open fix task even across its cell-projects.
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
from typing import TYPE_CHECKING, cast
from uuid import UUID, uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
@@ -89,7 +89,7 @@ async def _make_ci_watch_task(
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
project_id=project.id,
project_id=cast("UUID", project.id),
status=TaskStatus.PENDING,
source=source,
confirmed_by_human=True,
@@ -7,8 +7,8 @@ monorepo gets at most one open dependency-update task across its cell-projects.
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
from typing import TYPE_CHECKING, cast
from uuid import UUID, uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
@@ -87,7 +87,7 @@ async def _make_task(
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
project_id=project.id,
project_id=cast("UUID", project.id),
status=TaskStatus.PENDING,
source=source,
confirmed_by_human=True,
@@ -8,8 +8,8 @@ once per cell-project after a re-point). Re-review on a new head SHA still works
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID, uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
@@ -72,12 +72,18 @@ async def test_sibling_project_same_pr_is_deduped(db_session: AsyncSession) -> N
svc = get_task_service(db_session)
first = await svc.ingest_external_pr(
project_id=fe.id, pr=_pr("abc123"), created_by=SYSTEM_UUID, team=Team.FRONTEND
project_id=cast("UUID", fe.id),
pr=_pr("abc123"),
created_by=SYSTEM_UUID,
team=Team.FRONTEND,
)
assert first is not None # first review opens
# Same PR + same head, sibling project on the SAME repo → no second review.
dup = await svc.ingest_external_pr(
project_id=be.id, pr=_pr("abc123"), created_by=SYSTEM_UUID, team=Team.BACKEND
project_id=cast("UUID", be.id),
pr=_pr("abc123"),
created_by=SYSTEM_UUID,
team=Team.BACKEND,
)
assert dup is None
@@ -88,12 +94,16 @@ async def test_exists_is_repo_scoped(db_session: AsyncSession) -> None:
be = await _seed(db_session, "gca-backend", _REPO)
svc = get_task_service(db_session)
await svc.ingest_external_pr(
project_id=fe.id, pr=_pr("abc123"), created_by=SYSTEM_UUID, team=Team.FRONTEND
project_id=cast("UUID", fe.id),
pr=_pr("abc123"),
created_by=SYSTEM_UUID,
team=Team.FRONTEND,
)
# The sibling project sees the existing review (the fix); a new head SHA does not.
assert await svc.external_review_task_exists(be.id, 131, "abc123") is True
assert await svc.external_review_task_exists(be.id, 131, "newsha") is False
be_id = cast("UUID", be.id)
assert await svc.external_review_task_exists(be_id, 131, "abc123") is True
assert await svc.external_review_task_exists(be_id, 131, "newsha") is False
@pytest.mark.asyncio
@@ -102,11 +112,17 @@ async def test_different_repo_not_deduped(db_session: AsyncSession) -> None:
other = await _seed(db_session, "other", _OTHER_REPO)
svc = get_task_service(db_session)
await svc.ingest_external_pr(
project_id=fe.id, pr=_pr("abc123"), created_by=SYSTEM_UUID, team=Team.FRONTEND
project_id=cast("UUID", fe.id),
pr=_pr("abc123"),
created_by=SYSTEM_UUID,
team=Team.FRONTEND,
)
# A genuinely different repo with the same PR number is reviewed independently.
created = await svc.ingest_external_pr(
project_id=other.id, pr=_pr("abc123"), created_by=SYSTEM_UUID, team=Team.BACKEND
project_id=cast("UUID", other.id),
pr=_pr("abc123"),
created_by=SYSTEM_UUID,
team=Team.BACKEND,
)
assert created is not None
@@ -7,8 +7,8 @@ operator can't opt a project in.
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
from typing import TYPE_CHECKING, cast
from uuid import UUID, uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
@@ -55,7 +55,7 @@ async def test_update_sets_autonomy_opt_ins(db_session: AsyncSession) -> None:
svc = get_project_service(db_session)
await svc.update(
project.id,
cast("UUID", project.id),
ProjectUpdate(
ci_watch_enabled=True,
ci_watch_workflow="ci.yml",
@@ -64,7 +64,7 @@ async def test_update_sets_autonomy_opt_ins(db_session: AsyncSession) -> None:
),
)
reloaded = await svc.get(project.id)
reloaded = await svc.get(cast("UUID", project.id))
assert reloaded is not None
assert reloaded.ci_watch_enabled is True
assert reloaded.ci_watch_workflow == "ci.yml"
+48
View File
@@ -509,6 +509,54 @@ async def test_create_entry_learning_calls_record_learning(
mock_optimal.record_learning.assert_awaited()
@pytest.mark.asyncio
async def test_nonprivate_entry_is_indexed_to_shared_corpus(
journal_setup: dict,
) -> None:
"""A non-private reflection is embedded into the shared JOURNALS index."""
svc = journal_setup["svc"]
journal = await svc.get_or_create_journal(journal_setup["agent_id"])
mock_optimal = _AsyncMock()
mock_optimal.index_journal_entry = _AsyncMock(return_value=None)
svc._optimal_service = mock_optimal
await svc.create_entry(
JournalEntryCreate(
journal_id=journal.id,
type=JournalEntryType.DECISION_LOG,
title="t",
content="a shareable decision",
is_private=False,
)
)
await drain_rag_index_tasks()
mock_optimal.index_journal_entry.assert_awaited()
@pytest.mark.asyncio
async def test_private_entry_not_indexed_to_shared_corpus(
journal_setup: dict,
) -> None:
"""A PRIVATE reflection must NOT enter the cross-agent JOURNALS index."""
svc = journal_setup["svc"]
journal = await svc.get_or_create_journal(journal_setup["agent_id"])
mock_optimal = _AsyncMock()
mock_optimal.index_journal_entry = _AsyncMock(return_value=None)
svc._optimal_service = mock_optimal
await svc.create_entry(
JournalEntryCreate(
journal_id=journal.id,
type=JournalEntryType.DECISION_LOG,
title="t",
content="a private reflection",
is_private=True,
)
)
await drain_rag_index_tasks()
mock_optimal.index_journal_entry.assert_not_awaited()
# ---------------------------------------------------------------------------
# list_entries — filter by task_id
# ---------------------------------------------------------------------------
+112
View File
@@ -0,0 +1,112 @@
"""Playbook curation routes — Auditor/CEO list + approve/reject; others 403."""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.playbooks import router as playbooks_router
from roboco.models import AgentRole
from roboco.models.permissions import AgentContext
from roboco.models.playbook import PlaybookCreate
from roboco.services.playbook import PlaybookService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
def _build_app(db_session: AsyncSession, role: AgentRole, agent_id: UUID) -> FastAPI:
app = FastAPI()
app.include_router(playbooks_router, prefix="/api/playbooks")
async def _override_db() -> AsyncIterator[AsyncSession]:
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(agent_id=agent_id, role=role, team=None)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
return app
async def _seed_draft(db_session: AsyncSession, title: str = "Retry flaky pg") -> UUID:
pb = await PlaybookService(db_session).draft(
PlaybookCreate(
title=title,
problem="connection resets intermittently",
procedure="1. retry with backoff",
tags=["backend"],
),
created_by=uuid4(),
)
return pb.id
@pytest_asyncio.fixture
async def auditor_client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
app = _build_app(db_session, AgentRole.AUDITOR, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_list_drafts_as_auditor(
db_session: AsyncSession, auditor_client: AsyncClient
) -> None:
await _seed_draft(db_session, title="Draft to list")
resp = await auditor_client.get("/api/playbooks", params={"status": "draft"})
assert resp.status_code == HTTPStatus.OK
titles = [p["title"] for p in resp.json()]
assert "Draft to list" in titles
@pytest.mark.asyncio
async def test_approve_as_auditor_flips_to_approved(
db_session: AsyncSession, auditor_client: AsyncClient
) -> None:
pid = await _seed_draft(db_session, title="Approve me")
resp = await auditor_client.post(f"/api/playbooks/{pid}/approve")
assert resp.status_code == HTTPStatus.OK
assert resp.json()["status"] == "approved"
@pytest.mark.asyncio
async def test_reject_as_auditor_archives(
db_session: AsyncSession, auditor_client: AsyncClient
) -> None:
pid = await _seed_draft(db_session, title="Reject me")
resp = await auditor_client.post(
f"/api/playbooks/{pid}/reject", json={"reason": "duplicate of an existing one"}
)
assert resp.status_code == HTTPStatus.OK
assert resp.json()["status"] == "archived"
@pytest.mark.asyncio
async def test_approve_missing_is_404(auditor_client: AsyncClient) -> None:
resp = await auditor_client.post(f"/api/playbooks/{uuid4()}/approve")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_non_curator_is_forbidden(db_session: AsyncSession) -> None:
pid = await _seed_draft(db_session, title="Guarded")
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
get_resp = await client.get("/api/playbooks")
approve_resp = await client.post(f"/api/playbooks/{pid}/approve")
assert get_resp.status_code == HTTPStatus.FORBIDDEN
assert approve_resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
+145
View File
@@ -0,0 +1,145 @@
"""PlaybookService — draft + Auditor curation transitions (real Postgres)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from roboco.config import settings as cfg
from roboco.models.base import PlaybookStatus
from roboco.models.playbook import PlaybookCreate
from roboco.services.base import ConflictError, NotFoundError
from roboco.services.playbook import PlaybookService
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
def _create(title: str = "Retry a flaky pg test", **kw: Any) -> PlaybookCreate:
base: dict[str, Any] = {
"title": title,
"problem": "A pg integration test fails intermittently on connection reset.",
"procedure": "1. Wrap the fixture in a retry.\n2. Assert idempotency.",
"tags": ["backend"],
"scope": "org",
}
base.update(kw)
return PlaybookCreate(**base)
@pytest.mark.asyncio
async def test_draft_creates_a_draft_with_derived_slug(
db_session: AsyncSession,
) -> None:
svc = PlaybookService(db_session)
pb = await svc.draft(_create(title="Retry Flaky PG!"), created_by=uuid4())
assert pb.status == PlaybookStatus.DRAFT
assert pb.slug == "retry-flaky-pg"
@pytest.mark.asyncio
async def test_draft_then_approve_flips_status_and_stamps(
db_session: AsyncSession,
) -> None:
svc = PlaybookService(db_session)
auditor = uuid4()
pb = await svc.draft(_create(), created_by=uuid4())
approved = await svc.approve(pb.id, approver_id=auditor)
assert approved.status == PlaybookStatus.APPROVED
assert approved.approved_by == auditor
assert approved.approved_at is not None
@pytest.mark.asyncio
async def test_reject_archives(db_session: AsyncSession) -> None:
svc = PlaybookService(db_session)
pb = await svc.draft(_create(), created_by=uuid4())
out = await svc.reject(
pb.id, approver_id=uuid4(), reason="duplicate of an existing one"
)
assert out.status == PlaybookStatus.ARCHIVED
@pytest.mark.asyncio
async def test_list_drafts_and_approved_partition(db_session: AsyncSession) -> None:
svc = PlaybookService(db_session)
d = await svc.draft(_create(title="Draft one"), created_by=uuid4())
a = await svc.draft(_create(title="Approved one"), created_by=uuid4())
await svc.approve(a.id, approver_id=uuid4())
draft_ids = {p.id for p in await svc.list_drafts()}
approved_ids = {p.id for p in await svc.list_approved()}
assert d.id in draft_ids and d.id not in approved_ids
assert a.id in approved_ids and a.id not in draft_ids
@pytest.mark.asyncio
async def test_duplicate_slug_raises_conflict(db_session: AsyncSession) -> None:
svc = PlaybookService(db_session)
await svc.draft(_create(title="Same Title"), created_by=uuid4())
with pytest.raises(ConflictError):
await svc.draft(_create(title="Same Title"), created_by=uuid4())
@pytest.mark.asyncio
async def test_approve_missing_raises_notfound(db_session: AsyncSession) -> None:
svc = PlaybookService(db_session)
with pytest.raises(NotFoundError):
await svc.approve(uuid4(), approver_id=uuid4())
@pytest.mark.asyncio
async def test_source_task_id_is_recorded(db_session: AsyncSession) -> None:
svc = PlaybookService(db_session)
task_id = uuid4()
pb = await svc.draft(_create(source_task_id=task_id), created_by=uuid4())
assert str(task_id) in pb.source_task_ids
@pytest.mark.asyncio
async def test_approve_indexes_when_org_memory_on(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "org_memory_enabled", True)
fake_optimal = AsyncMock()
fake_optimal.index_playbook = AsyncMock()
monkeypatch.setattr(
"roboco.services.optimal.get_optimal_service",
AsyncMock(return_value=fake_optimal),
)
svc = PlaybookService(db_session)
pb = await svc.draft(_create(title="Index me"), created_by=uuid4())
await svc.approve(pb.id, approver_id=uuid4())
fake_optimal.index_playbook.assert_awaited_once()
@pytest.mark.asyncio
async def test_approve_does_not_index_when_off(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "org_memory_enabled", False)
getter = AsyncMock()
monkeypatch.setattr("roboco.services.optimal.get_optimal_service", getter)
svc = PlaybookService(db_session)
pb = await svc.draft(_create(title="Do not index"), created_by=uuid4())
await svc.approve(pb.id, approver_id=uuid4())
getter.assert_not_awaited()
@pytest.mark.asyncio
async def test_approve_survives_index_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "org_memory_enabled", True)
fake_optimal = AsyncMock()
fake_optimal.index_playbook = AsyncMock(side_effect=RuntimeError("ollama down"))
monkeypatch.setattr(
"roboco.services.optimal.get_optimal_service",
AsyncMock(return_value=fake_optimal),
)
svc = PlaybookService(db_session)
pb = await svc.draft(_create(title="Resilient"), created_by=uuid4())
approved = await svc.approve(pb.id, approver_id=uuid4()) # must not raise
assert approved.status == PlaybookStatus.APPROVED
+235
View File
@@ -0,0 +1,235 @@
"""Release-manager route coverage — CEO-only GET / approve / reject."""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.release import router as release_router
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import TaskNature, TaskStatus, TaskType
from roboco.models.permissions import AgentContext
from roboco.services.release_executor import ReleaseResult
from roboco.services.release_readiness import ReleaseReadinessReport, report_to_dict
from roboco.services.task import RELEASE_MANAGER_SOURCE
from sqlalchemy import delete
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
_VERSION = "0.13.0"
def _report() -> ReleaseReadinessReport:
return ReleaseReadinessReport(
proposed_version=_VERSION,
bump_kind="minor",
change_summary=["feat: a thing"],
drafted_changelog=f"## [{_VERSION}] - 2026-06-25\n\n### Added\n- a thing\n",
version_bump_plan=["pyproject.toml"],
gaps=[],
migration_notes=[],
gate_state="green",
)
async def _seed_agent(session: AsyncSession, role: AgentRole, slug: str) -> AgentTable:
agent = AgentTable(
id=uuid4(),
name=slug,
slug=f"{slug}-{uuid4().hex[:6]}",
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
session.add(agent)
await session.flush()
return agent
async def _seed_proposal(session: AsyncSession) -> TaskTable:
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
secretary = await _seed_agent(session, AgentRole.SECRETARY, "secretary")
project = ProjectTable(
id=uuid4(),
name="RoboCo",
slug=f"roboco-{uuid4().hex[:6]}",
git_url="https://example.com/roboco.git",
assigned_cell=Team.BACKEND,
created_by=system.id,
)
session.add(project)
await session.flush()
task = TaskTable(
id=uuid4(),
title=f"Release proposal: v{_VERSION}",
description="proposal body",
acceptance_criteria=["CEO approves"],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.ADMINISTRATIVE,
nature=TaskNature.NON_TECHNICAL,
project_id=project.id,
created_by=system.id,
assigned_to=secretary.id,
team=Team.MAIN_PM,
source=RELEASE_MANAGER_SOURCE,
confirmed_by_human=False,
orchestration_markers={"release_report": report_to_dict(_report())},
)
session.add(task)
await session.flush()
return task
def _build_app(db_session: AsyncSession, role: AgentRole, agent_id: UUID) -> FastAPI:
app = FastAPI()
app.include_router(release_router, prefix="/api/release")
async def _override_db() -> AsyncIterator[AsyncSession]:
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(agent_id=agent_id, role=role, team=None)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
return app
@pytest_asyncio.fixture
async def ceo_client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
app = _build_app(db_session, AgentRole.CEO, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
# The approve/reject routes call db.commit() (real behavior), so a held
# proposal persists past the per-test rollback. list_open_release_proposals()
# is global (source-scoped, all projects), so clean up to avoid leaking into
# the engine tests that assert on it.
await db_session.execute(
delete(TaskTable).where(TaskTable.source == RELEASE_MANAGER_SOURCE)
)
await db_session.commit()
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_get_proposal_returns_open_proposal(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
await _seed_proposal(db_session)
resp = await ceo_client.get("/api/release/proposal")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["report"]["proposed_version"] == _VERSION
assert body["report"]["bump_kind"] == "minor"
@pytest.mark.asyncio
async def test_get_proposal_404_when_none(ceo_client: AsyncClient) -> None:
resp = await ceo_client.get("/api/release/proposal")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_approve_runs_executor_and_completes(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_proposal(db_session)
published = ReleaseResult(
status="published",
version=_VERSION,
files_changed=["pyproject.toml"],
commit_sha="abc123",
release_url=f"https://github.com/x/roboco/releases/tag/v{_VERSION}",
detail="ok",
)
fake_executor = AsyncMock()
fake_executor.execute = AsyncMock(return_value=published)
with patch(
"roboco.services.release_proposal.get_release_executor",
AsyncMock(return_value=fake_executor),
):
resp = await ceo_client.post("/api/release/proposal/approve")
assert resp.status_code == HTTPStatus.OK
assert resp.json()["status"] == "published"
fake_executor.execute.assert_awaited_once()
refreshed = await db_session.get(TaskTable, task.id)
assert refreshed is not None
assert refreshed.status == TaskStatus.COMPLETED
@pytest.mark.asyncio
async def test_approve_gate_failure_keeps_proposal_open(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_proposal(db_session)
failed = ReleaseResult(
status="gate_failed",
version=_VERSION,
files_changed=["pyproject.toml"],
commit_sha=None,
release_url=None,
detail="make quality failed",
)
fake_executor = AsyncMock()
fake_executor.execute = AsyncMock(return_value=failed)
with patch(
"roboco.services.release_proposal.get_release_executor",
AsyncMock(return_value=fake_executor),
):
resp = await ceo_client.post("/api/release/proposal/approve")
assert resp.status_code == HTTPStatus.OK
assert resp.json()["status"] == "gate_failed"
refreshed = await db_session.get(TaskTable, task.id)
assert refreshed is not None
assert refreshed.status == TaskStatus.PENDING # still held
@pytest.mark.asyncio
async def test_reject_records_changes_and_keeps_open(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_proposal(db_session)
resp = await ceo_client.post(
"/api/release/proposal/reject",
json={"required_changes": "Tighten the CHANGELOG wording for the API change."},
)
assert resp.status_code == HTTPStatus.OK
assert "Tighten the CHANGELOG" in (resp.json()["required_changes"] or "")
refreshed = await db_session.get(TaskTable, task.id)
assert refreshed is not None
assert refreshed.status == TaskStatus.PENDING # stays held for revision
@pytest.mark.asyncio
async def test_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
await _seed_proposal(db_session)
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
get_resp = await client.get("/api/release/proposal")
approve_resp = await client.post("/api/release/proposal/approve")
reject_resp = await client.post(
"/api/release/proposal/reject", json={"required_changes": "x" * 20}
)
assert get_resp.status_code == HTTPStatus.FORBIDDEN
assert approve_resp.status_code == HTTPStatus.FORBIDDEN
assert reject_resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()