feat(git): resolve a coordination root's repo from its product

Branch->project resolution (_project_slug_for_branch, _workspace_for_branch)
read task.project_id, which is null for a coordination root — so root-level
git ops (the root->master PR, the CEO merge) could not resolve a workspace.
A new _project_for_task falls through to the product's first distinct repo
(monorepo => the single repo) when project_id is null. Purely additive: a
task with a project_id resolves exactly as before; only the previously-
unresolvable root case changes.
This commit is contained in:
Renn F
2026-06-04 06:22:01 +02:00
parent ef72e72bf7
commit 2ee610f6cf
2 changed files with 55 additions and 4 deletions
+27 -4
View File
@@ -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)
+28
View File
@@ -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
# ---------------------------------------------------------------------------