diff --git a/roboco/services/git.py b/roboco/services/git.py index ddbc814e..7fe852e5 100644 --- a/roboco/services/git.py +++ b/roboco/services/git.py @@ -1876,13 +1876,37 @@ class GitService(BaseService): ) return result.scalar_one_or_none() + async def _project_for_task(self, task: Any) -> Any | None: + """Resolve the Project whose repo a task's branch lives in. + + A normal task carries ``project_id``. A coordination root carries a + product (cell->repo map) but no project of its own — its + ``feature/main_pm/{root}`` integration branch lives in the product's + repo(s). Fall through to the product's first distinct repo (monorepo => + the single repo) so root-level git ops (create_pr root->master, the CEO + merge) resolve a workspace. Purely additive: a task WITH project_id + resolves exactly as before. + """ + project_service = get_project_service(self.session) + if task.project_id is not None: + return await project_service.get(UUID(str(task.project_id))) + product_id = getattr(task, "product_id", None) + if product_id is None: + return None + from roboco.services.product import get_product_service + + product_service = get_product_service(self.session) + project_ids = await product_service.distinct_project_ids(UUID(str(product_id))) + if not project_ids: + return None + return await project_service.get(project_ids[0]) + async def _project_slug_for_branch(self, branch_name: str) -> str | None: """Resolve project slug via the task that owns the branch.""" task = await self._task_for_branch(branch_name) if task is None: return None - project_service = get_project_service(self.session) - project = await project_service.get(UUID(str(task.project_id))) + project = await self._project_for_task(task) return project.slug if project else None @staticmethod @@ -1920,8 +1944,7 @@ class GitService(BaseService): task = await self._task_for_branch(branch_name) if task is None: raise NotFoundError("Branch", branch_name) - project_service = get_project_service(self.session) - project = await project_service.get(UUID(str(task.project_id))) + project = await self._project_for_task(task) if project is None: raise NotFoundError("Project", str(task.project_id)) workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id) diff --git a/tests/unit/services/test_git.py b/tests/unit/services/test_git.py index 0cd96d38..ee2d51d1 100644 --- a/tests/unit/services/test_git.py +++ b/tests/unit/services/test_git.py @@ -98,6 +98,34 @@ async def test_project_slug_for_branch_none_when_no_task() -> None: assert await svc._project_slug_for_branch("missing") is None +@pytest.mark.asyncio +async def test_project_for_task_resolves_coordination_root_via_product() -> None: + """A project-less coordination root resolves its repo from the product map.""" + pid = uuid4() + fake_project = MagicMock(slug="roboco") + task = MagicMock(project_id=None, product_id=uuid4()) + svc = _service() + product_svc = MagicMock(distinct_project_ids=AsyncMock(return_value=[pid])) + with ( + _patch_project_service(fake_project), + patch("roboco.services.product.get_product_service", return_value=product_svc), + ): + out = await svc._project_for_task(task) + assert out is fake_project + product_svc.distinct_project_ids.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_project_for_task_uses_project_id_when_present() -> None: + """A normal task resolves by project_id exactly as before (additive change).""" + fake_project = MagicMock(slug="roboco") + task = MagicMock(project_id=uuid4(), product_id=None) + svc = _service() + with _patch_project_service(fake_project): + out = await svc._project_for_task(task) + assert out is fake_project + + # --------------------------------------------------------------------------- # diff: derives parent + invokes git diff # ---------------------------------------------------------------------------