Files
roboco/tests/integration/test_product_service.py
T
6cce556536 feat(git): coordination root cuts a Main-PM integration branch per repo (#58)
The coordination/fan-out root carries a product (cell->repo map) but no
project of its own, and was forced branchless — so a cell's parent-branch
resolution fell back to the project default (master), and cell completion
merged each cell straight to master, bypassing the Main-PM integration
point and the CEO merge gate.

Per the locked branch model (master <- feature/main_pm/{root} <- cell <-
dev), the root is now the Main-PM integration point: on claim it cuts
feature/main_pm/{root} off master in EACH distinct repo the product spans
(monorepo => 1, multi-repo => N). Cells then branch off it via the existing
ancestor-branch resolution, so cell work never targets master.

- ProductService.distinct_project_ids: enumerate the repos a product spans
- TaskService._create_branch_in_project: project-parameterized branch
  creation split out of _auto_create_branch
- TaskService._ensure_coordination_root_branches: cut the integration
  branch in each repo; graceful empty when the product has no cell map yet
- _ensure_branch_for_task routes a product-backed root here, not to no-op

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-04 05:48:21 +02:00

182 lines
5.7 KiB
Python

from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable, ProjectTable
from roboco.foundation.identity import Team
from roboco.models import AgentRole, AgentStatus
from roboco.models.product import ProductCellMapping, ProductCreate, ProductUpdate
from roboco.services.base import ConflictError
from roboco.services.product import ProductService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def product_setup(db_session: AsyncSession) -> AsyncIterator[dict]:
system = AgentTable(
id=uuid4(),
name="System",
slug=f"system-{uuid4().hex[:8]}",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="s",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(system)
await db_session.flush()
projects = {}
for cell in (Team.BACKEND, Team.FRONTEND, Team.UX_UI):
p = ProjectTable(
id=uuid4(),
name=cell.value,
slug=f"{cell.value}-{uuid4().hex[:6]}",
git_url="https://example.com/r.git",
assigned_cell=cell,
created_by=system.id,
)
db_session.add(p)
projects[cell] = p
await db_session.flush()
yield {
"svc": ProductService(db_session),
"creator": system.id,
"projects": projects,
}
@pytest.mark.asyncio
async def test_create_with_cells_and_project_for(product_setup: dict) -> None:
svc = product_setup["svc"]
projects = product_setup["projects"]
product = await svc.create(
ProductCreate(
name="RoboCo",
slug=f"roboco-{uuid4().hex[:6]}",
cells=[
ProductCellMapping(
team=Team.BACKEND, project_id=projects[Team.BACKEND].id
),
ProductCellMapping(
team=Team.FRONTEND, project_id=projects[Team.FRONTEND].id
),
],
),
created_by=product_setup["creator"],
)
assert await svc.project_for(product.id, Team.BACKEND) == projects[Team.BACKEND].id
assert (
await svc.project_for(product.id, Team.FRONTEND) == projects[Team.FRONTEND].id
)
# missing-team mapping -> None (graceful fallback happens in the caller)
assert await svc.project_for(product.id, Team.UX_UI) is None
@pytest.mark.asyncio
async def test_shared_project_across_cells(product_setup: dict) -> None:
"""Monorepo: every cell maps to the same Project."""
svc = product_setup["svc"]
shared = product_setup["projects"][Team.BACKEND].id
product = await svc.create(
ProductCreate(
name="Mono",
slug=f"mono-{uuid4().hex[:6]}",
cells=[
ProductCellMapping(team=c, project_id=shared)
for c in (Team.BACKEND, Team.FRONTEND, Team.UX_UI)
],
),
created_by=product_setup["creator"],
)
for c in (Team.BACKEND, Team.FRONTEND, Team.UX_UI):
assert await svc.project_for(product.id, c) == shared
@pytest.mark.asyncio
async def test_distinct_project_ids_monorepo_and_multirepo(product_setup: dict) -> None:
"""One integration branch per DISTINCT repo: monorepo => 1, multi-repo => N."""
svc = product_setup["svc"]
projects = product_setup["projects"]
shared = projects[Team.BACKEND].id
mono = await svc.create(
ProductCreate(
name="Mono",
slug=f"mono-{uuid4().hex[:6]}",
cells=[
ProductCellMapping(team=c, project_id=shared)
for c in (Team.BACKEND, Team.FRONTEND, Team.UX_UI)
],
),
created_by=product_setup["creator"],
)
assert await svc.distinct_project_ids(mono.id) == [shared]
multi = await svc.create(
ProductCreate(
name="Multi",
slug=f"multi-{uuid4().hex[:6]}",
cells=[
ProductCellMapping(team=c, project_id=projects[c].id)
for c in (Team.BACKEND, Team.FRONTEND, Team.UX_UI)
],
),
created_by=product_setup["creator"],
)
assert set(await svc.distinct_project_ids(multi.id)) == {
projects[Team.BACKEND].id,
projects[Team.FRONTEND].id,
projects[Team.UX_UI].id,
}
@pytest.mark.asyncio
async def test_duplicate_slug_conflicts(product_setup: dict) -> None:
svc = product_setup["svc"]
slug = f"dup-{uuid4().hex[:6]}"
await svc.create(
ProductCreate(name="A", slug=slug), created_by=product_setup["creator"]
)
with pytest.raises(ConflictError):
await svc.create(
ProductCreate(name="B", slug=slug), created_by=product_setup["creator"]
)
@pytest.mark.asyncio
async def test_update_replaces_cells(product_setup: dict) -> None:
svc = product_setup["svc"]
projects = product_setup["projects"]
product = await svc.create(
ProductCreate(
name="U",
slug=f"u-{uuid4().hex[:6]}",
cells=[
ProductCellMapping(
team=Team.BACKEND, project_id=projects[Team.BACKEND].id
)
],
),
created_by=product_setup["creator"],
)
await svc.update(
product.id,
ProductUpdate(
cells=[
ProductCellMapping(team=Team.UX_UI, project_id=projects[Team.UX_UI].id)
]
),
)
assert await svc.project_for(product.id, Team.BACKEND) is None
assert await svc.project_for(product.id, Team.UX_UI) == projects[Team.UX_UI].id