mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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>
269 lines
9.1 KiB
Python
269 lines
9.1 KiB
Python
"""roboco.services.pitch — CRUD + approve/reject orchestration (mocked deps).
|
|
|
|
The approve path constructs real domain models (ProjectCreate, ProductCellMapping,
|
|
TaskCreateRequest) but the downstream services and the GitHub provisioner are
|
|
faked, so the test exercises the orchestration logic without a DB or network.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from roboco.db.tables import PitchTable
|
|
from roboco.foundation.identity import Team
|
|
from roboco.models.pitch import PitchCreate, PitchStatus
|
|
from roboco.services import pitch as pitch_module
|
|
from roboco.services.base import ConflictError
|
|
from roboco.services.github_provisioning import (
|
|
GitHubProvisioningService,
|
|
ProvisionedRepo,
|
|
ProvisioningDisabledError,
|
|
)
|
|
from roboco.services.pitch import PitchService
|
|
|
|
|
|
def _session() -> MagicMock:
|
|
s = MagicMock()
|
|
s.add = MagicMock()
|
|
s.flush = AsyncMock()
|
|
return s
|
|
|
|
|
|
def _pitch(**kw: Any) -> PitchTable:
|
|
defaults: dict[str, Any] = {
|
|
"id": uuid4(),
|
|
"title": "Widget",
|
|
"slug": "widget",
|
|
"problem": "people need widgets",
|
|
"proposed_solution": "build a widget service",
|
|
"target_cells": ["backend"],
|
|
"status": "proposed",
|
|
"created_by": uuid4(),
|
|
}
|
|
defaults.update(kw)
|
|
return PitchTable(**defaults)
|
|
|
|
|
|
class _FakeProvisioning(GitHubProvisioningService):
|
|
def __init__(self, *, enabled: bool = True) -> None:
|
|
self._enabled = enabled
|
|
self.created: list[str] = []
|
|
|
|
@property
|
|
def enabled(self) -> bool:
|
|
return self._enabled
|
|
|
|
async def create_repo(
|
|
self, name: str, description: str = "", *, private: bool = True
|
|
) -> ProvisionedRepo:
|
|
_ = (description, private)
|
|
self.created.append(name)
|
|
return ProvisionedRepo(
|
|
full_name=f"org/{name}",
|
|
clone_url=f"https://github.com/org/{name}.git",
|
|
html_url=f"https://github.com/org/{name}",
|
|
)
|
|
|
|
async def close(self) -> None:
|
|
return None
|
|
|
|
|
|
def _patch_topology(monkeypatch: pytest.MonkeyPatch) -> dict[str, MagicMock]:
|
|
proj = 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()
|
|
task.id = uuid4()
|
|
task_svc = MagicMock()
|
|
task_svc.create = AsyncMock(return_value=task)
|
|
monkeypatch.setattr(pitch_module, "get_task_service", lambda _s: task_svc)
|
|
|
|
main_pm = MagicMock()
|
|
main_pm.id = uuid4()
|
|
agent_svc = MagicMock()
|
|
agent_svc.get_by_slug = AsyncMock(return_value=main_pm)
|
|
monkeypatch.setattr(pitch_module, "get_agent_service", lambda _s: agent_svc)
|
|
|
|
return {"project": project_svc, "product": product_svc, "task": task_svc}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_persists(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
session = _session()
|
|
svc = PitchService(session)
|
|
monkeypatch.setattr(svc, "get_by_slug", AsyncMock(return_value=None))
|
|
pitch = await svc.create(
|
|
PitchCreate(
|
|
title="Widget",
|
|
slug="widget",
|
|
problem="p",
|
|
proposed_solution="s",
|
|
target_cells=[Team.BACKEND, Team.FRONTEND],
|
|
),
|
|
created_by=uuid4(),
|
|
)
|
|
assert pitch.slug == "widget"
|
|
assert pitch.status == PitchStatus.PROPOSED.value
|
|
assert pitch.target_cells == ["backend", "frontend"]
|
|
session.add.assert_called_once()
|
|
session.flush.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_conflict_on_duplicate_slug(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
svc = PitchService(_session())
|
|
monkeypatch.setattr(svc, "get_by_slug", AsyncMock(return_value=_pitch()))
|
|
with pytest.raises(ConflictError):
|
|
await svc.create(
|
|
PitchCreate(
|
|
title="Widget",
|
|
slug="widget",
|
|
problem="p",
|
|
proposed_solution="s",
|
|
target_cells=[Team.BACKEND],
|
|
),
|
|
created_by=uuid4(),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reject_sets_status(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
svc = PitchService(_session())
|
|
pitch = _pitch()
|
|
monkeypatch.setattr(svc, "get", AsyncMock(return_value=pitch))
|
|
result = await svc.reject(pitch.id, "not aligned with the charter", uuid4())
|
|
assert result.status == PitchStatus.REJECTED.value
|
|
assert result.decision_notes == "not aligned with the charter"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_single_cell_provisions_project_and_task(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
svc = PitchService(_session())
|
|
pitch = _pitch(target_cells=["backend"])
|
|
monkeypatch.setattr(svc, "get", AsyncMock(return_value=pitch))
|
|
svcs = _patch_topology(monkeypatch)
|
|
prov = _FakeProvisioning(enabled=True)
|
|
|
|
result = await svc.approve(
|
|
pitch.id, "approved for build", uuid4(), provisioning=prov
|
|
)
|
|
|
|
assert result.status == PitchStatus.PROVISIONED.value
|
|
assert result.seed_task_id is not None
|
|
assert result.provisioned_project_ids is not None
|
|
assert len(result.provisioned_project_ids) == len(pitch.target_cells)
|
|
assert result.provisioned_product_id is None
|
|
assert prov.created == ["widget"]
|
|
svcs["product"].create.assert_not_called()
|
|
svcs["task"].create.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_multi_cell_creates_product(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
svc = PitchService(_session())
|
|
pitch = _pitch(slug="multi", target_cells=["backend", "frontend"])
|
|
monkeypatch.setattr(svc, "get", AsyncMock(return_value=pitch))
|
|
svcs = _patch_topology(monkeypatch)
|
|
prov = _FakeProvisioning(enabled=True)
|
|
|
|
result = await svc.approve(pitch.id, "approved", uuid4(), provisioning=prov)
|
|
|
|
assert result.provisioned_product_id is not None
|
|
assert result.provisioned_project_ids is not None
|
|
assert len(result.provisioned_project_ids) == len(pitch.target_cells)
|
|
assert prov.created == ["multi-backend", "multi-frontend"]
|
|
svcs["product"].create.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_rejects_when_not_proposed(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
svc = PitchService(_session())
|
|
monkeypatch.setattr(
|
|
svc, "get", AsyncMock(return_value=_pitch(status="provisioned"))
|
|
)
|
|
with pytest.raises(ConflictError):
|
|
await svc.approve(uuid4(), "x", uuid4(), provisioning=_FakeProvisioning())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_blocked_when_provisioning_disabled(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
svc = PitchService(_session())
|
|
monkeypatch.setattr(svc, "get", AsyncMock(return_value=_pitch()))
|
|
with pytest.raises(ProvisioningDisabledError):
|
|
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
|