"""GET /api/tasks/{id}/collision-map — the reviewer/PM collision map route. Read-only feed for the panel's Collision tab: the task's own declared surface plus the surfaced siblings (same parent) that would collide with it. Mirrors test_task_findings_route.py's fixture shape. """ from __future__ import annotations from http import HTTPStatus from typing import TYPE_CHECKING, Any, cast 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.tasks import router as tasks_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 if TYPE_CHECKING: from collections.abc import AsyncIterator from sqlalchemy.ext.asyncio import AsyncSession @pytest_asyncio.fixture async def collision_client(db_session: AsyncSession) -> AsyncIterator[dict]: pm = AgentTable( id=uuid4(), name="PM", slug=f"pm-{uuid4().hex[:8]}", role=AgentRole.MAIN_PM, team=None, status=AgentStatus.ACTIVE, model_config={}, system_prompt="pm", capabilities=[], permissions={}, metrics={}, ) db_session.add(pm) await db_session.flush() project = ProjectTable( id=uuid4(), name="CM-Proj", slug=f"cm-proj-{uuid4().hex[:6]}", git_url="https://example.com/cm.git", assigned_cell=Team.BACKEND, created_by=pm.id, ) db_session.add(project) await db_session.flush() app = FastAPI() app.include_router(tasks_router, prefix="/api/tasks") async def _override_db() -> AsyncIterator[AsyncSession]: yield db_session async def _override_agent() -> AgentContext: return AgentContext( agent_id=cast("UUID", pm.id), role=AgentRole.MAIN_PM, team=None ) app.dependency_overrides[get_db] = _override_db app.dependency_overrides[get_agent_context] = _override_agent transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: yield {"client": client, "agent": pm, "project": project, "db": db_session} app.dependency_overrides.clear() def _task(setup: dict, **kw: Any) -> TaskTable: task = TaskTable( id=uuid4(), title=kw.pop("title", "t"), description=kw.pop("description", "d"), acceptance_criteria=["ac"], status=kw.pop("status", TaskStatus.IN_PROGRESS), priority=kw.pop("priority", 2), sequence=kw.pop("sequence", 0), task_type=TaskType.CODE, nature=TaskNature.TECHNICAL, project_id=setup["project"].id, created_by=setup["agent"].id, team=Team.BACKEND, **kw, ) setup["db"].add(task) return task _HDR = {"X-Agent-ID": "ignored", "X-Agent-Role": "main_pm"} @pytest.mark.asyncio async def test_collision_map_404_for_missing_task(collision_client: dict) -> None: client = collision_client["client"] response = await client.get(f"/api/tasks/{uuid4()}/collision-map", headers=_HDR) assert response.status_code == HTTPStatus.NOT_FOUND @pytest.mark.asyncio async def test_collision_map_empty_for_rootless_task(collision_client: dict) -> None: client = collision_client["client"] task = _task(collision_client, intends_to_touch=["roboco/services/git.py"]) await collision_client["db"].flush() response = await client.get(f"/api/tasks/{task.id}/collision-map", headers=_HDR) assert response.status_code == HTTPStatus.OK body = response.json() assert body["parent_task_id"] is None assert body["intends_to_touch"] == ["roboco/services/git.py"] assert body["siblings"] == [] @pytest.mark.asyncio async def test_collision_map_shows_overlapping_sibling(collision_client: dict) -> None: client = collision_client["client"] parent = _task(collision_client, title="parent", status=TaskStatus.PENDING) await collision_client["db"].flush() under = _task( collision_client, parent_task_id=parent.id, title="under review", intends_to_touch=["roboco/services/git.py"], sequence=0, ) _task( collision_client, parent_task_id=parent.id, title="colliding sibling", intends_to_touch=["roboco/services/git.py", "roboco/services/x.py"], sequence=1, ) await collision_client["db"].flush() response = await client.get(f"/api/tasks/{under.id}/collision-map", headers=_HDR) assert response.status_code == HTTPStatus.OK body = response.json() assert body["parent_task_id"] == str(parent.id) assert len(body["siblings"]) == 1 sib_entry = body["siblings"][0] assert sib_entry["title"] == "colliding sibling" assert "roboco/services/git.py" in sib_entry["overlap"] # panel path carries no actual files → drift is empty (the QA/gate # evidence envelopes populate it; the panel schema defaults to []). assert sib_entry["undeclared"] == [] @pytest.mark.asyncio async def test_collision_map_degrades_on_builder_failure( collision_client: dict, monkeypatch: pytest.MonkeyPatch ) -> None: # build_collision_context is called outside the get_subtasks try/except at # this site's call site; a raise there must still yield a 200 with no # siblings, never a 500 — mirrors qa.py's claim_review evidence pattern. client = collision_client["client"] parent = _task(collision_client, title="parent", status=TaskStatus.PENDING) await collision_client["db"].flush() under = _task( collision_client, parent_task_id=parent.id, title="under review", intends_to_touch=["roboco/services/git.py"], ) _task( collision_client, parent_task_id=parent.id, title="colliding sibling", intends_to_touch=["roboco/services/git.py"], ) await collision_client["db"].flush() def _boom(**_kw: Any) -> None: raise RuntimeError("collision builder exploded") monkeypatch.setattr("roboco.api.routes.tasks.build_collision_context", _boom) response = await client.get(f"/api/tasks/{under.id}/collision-map", headers=_HDR) assert response.status_code == HTTPStatus.OK body = response.json() assert body["siblings"] == [] assert body["intends_to_touch"] == ["roboco/services/git.py"] @pytest.mark.asyncio async def test_collision_map_omits_non_overlapping_sibling( collision_client: dict, ) -> None: client = collision_client["client"] parent = _task(collision_client, title="parent", status=TaskStatus.PENDING) await collision_client["db"].flush() under = _task( collision_client, parent_task_id=parent.id, title="under review", intends_to_touch=["roboco/services/git.py"], ) _task( collision_client, parent_task_id=parent.id, title="parallel sibling", intends_to_touch=["roboco/services/other.py"], ) await collision_client["db"].flush() response = await client.get(f"/api/tasks/{under.id}/collision-map", headers=_HDR) assert response.status_code == HTTPStatus.OK body = response.json() assert body["siblings"] == [] # no overlap, no shared migration