mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(megatask): per-cell project map root-subtasks (multi-project, multi-cell)
A MegaTask root-subtask can now target an ad-hoc per-cell project map — a
third targeting shape that mirrors the existing product fan-out root. In
RoboCo a project is per-cell (ProjectTable.assigned_cell); a monorepo is N
per-cell projects sharing one git_url. So 'multi-cell' IS 'multi-project',
and a task may mix per-cell projects across products or include OSS-library
projects not in any product.
Storage: migration 052 adds task_cell_projects (mirrors product_projects;
unique per (task, team)). TaskTable gains a cascade-delete cell_projects
relationship; TaskCreateRequest / TaskCreate / Task response carry the map.
Policy: batch.is_branchless_coordination + is_valid_batch_shape gain a
has_cell_projects param — a root-subtask targets exactly one of project /
product / cell-map; the umbrella still targets none. TaskService passes
has_cell_projects at every predicate call site and persists the rows in
create(). _ensure_branch_for_task cuts feature/main_pm/{root} per distinct
project in the map (via _distinct_projects_for_task); _require_target_or_umbrella
and _validate_batch_membership accept the map shape.
Fan-out: every distinct_project_ids site (task.py branch creation, routes
_project_for_complete + _resolve_project_for_merge, orchestrator
_ambient_projects_for_task, pr_review._project_slug_for, git._project_for_task)
generalizes to first-distinct-project-of-map-or-product. Choreographer
_resolve_subtask_project resolves a delegated subtask's cell from the parent's
cell map. The product-scoped _slugs_for_product intake helper is unchanged.
Intake: prompter._draft_cell_map extracts the per-cell map from the_work[].
_validate_batch_scope counts distinct projects across all drafts' cells
(>=2 min stays; one 2-cell draft satisfies it). create_task_from_draft
persists cell_projects for >=2-cell drafts (project_id/product_id None),
collapses a 1-cell map to the single-project shape, and leaves single-cell
top-level project_id drafts unchanged. _resolve_owning_team routes a
multi-cell map to Main PM (coordination root, like a product root — a cell
PM can't delegate cross-cell). propose_draft/propose_batch tool descriptions
declare the per-cell project_id (both Claude SDK + grok runtimes).
The umbrella stays branchless / pure-coordination / submit_root-rejected;
the CEO-escalation pr_number gate is not widened (the map root is
is_umbrella=False, mirroring a product root, so submit_root supplies it).
Single-cell root-subtasks and everything below them are byte-for-byte
unchanged. Un-run MegaTask waves (multi-cell drafts) become runnable.
This commit is contained in:
@@ -30,6 +30,7 @@ from roboco.api.schemas.tasks import (
|
||||
transform_update_data,
|
||||
)
|
||||
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
|
||||
from roboco.models.product import ProductCellMapping
|
||||
|
||||
_ORDER_DEFAULT = 0
|
||||
|
||||
@@ -291,6 +292,7 @@ def _stub_task(*, with_project: bool = False) -> SimpleNamespace:
|
||||
task_type=TaskType.CODE,
|
||||
project_id=uuid4(),
|
||||
product_id=None,
|
||||
cell_projects=[],
|
||||
project=(SimpleNamespace(slug="proj-1") if with_project else None),
|
||||
docs_complete=False,
|
||||
pr_created=False,
|
||||
@@ -348,6 +350,38 @@ def test_task_to_response_includes_slug_when_project_loaded() -> None:
|
||||
assert resp.project_slug == "proj-1"
|
||||
|
||||
|
||||
def test_task_to_response_serializes_cell_projects_when_loaded() -> None:
|
||||
"""An ad-hoc per-cell map (a multi-cell MegaTask root-subtask) round-trips
|
||||
into the response when the relationship is loaded."""
|
||||
be_proj, fe_proj = uuid4(), uuid4()
|
||||
stub = _stub_task()
|
||||
stub.project_id = None
|
||||
stub.product_id = None
|
||||
stub.cell_projects = [
|
||||
SimpleNamespace(team=Team.BACKEND, project_id=be_proj),
|
||||
SimpleNamespace(team=Team.FRONTEND, project_id=fe_proj),
|
||||
]
|
||||
fake_inspector = MagicMock()
|
||||
fake_inspector.unloaded = set() # cell_projects IS loaded
|
||||
with patch("roboco.api.schemas.tasks.sa_inspect", return_value=fake_inspector):
|
||||
resp = task_to_response(stub) # type: ignore[arg-type]
|
||||
assert resp.cell_projects == [
|
||||
ProductCellMapping(team=Team.BACKEND, project_id=be_proj),
|
||||
ProductCellMapping(team=Team.FRONTEND, project_id=fe_proj),
|
||||
]
|
||||
|
||||
|
||||
def test_task_to_response_omits_cell_projects_when_unloaded() -> None:
|
||||
"""A freshly-created task whose cell_projects relationship is unloaded
|
||||
serializes to [] rather than triggering a lazy load."""
|
||||
stub = _stub_task()
|
||||
fake_inspector = MagicMock()
|
||||
fake_inspector.unloaded = {"cell_projects"}
|
||||
with patch("roboco.api.schemas.tasks.sa_inspect", return_value=fake_inspector):
|
||||
resp = task_to_response(stub) # type: ignore[arg-type]
|
||||
assert resp.cell_projects == []
|
||||
|
||||
|
||||
def test_task_to_response_serializes_all_note_sections() -> None:
|
||||
"""Regression: pr_reviewer_notes / doc_notes / notes_structured MUST be in the
|
||||
response. The builder previously omitted them, so the panel showed them blank
|
||||
|
||||
@@ -46,10 +46,33 @@ def test_branchless_coordination_excludes_normal_and_root_subtasks() -> None:
|
||||
batch_id=uuid4(),
|
||||
parent_task_id=uuid4(),
|
||||
)
|
||||
# genuinely unroutable (none of project / product / batch) stays gated
|
||||
# genuinely unroutable (none of project / product / batch / cell-map) stays gated
|
||||
assert not is_branchless_coordination(project_id=None, product_id=None)
|
||||
|
||||
|
||||
def test_branchless_coordination_covers_ad_hoc_cell_map_root() -> None:
|
||||
# An ad-hoc per-cell project map (no project_id, no product_id, carries a
|
||||
# cell map) is a coordination root exactly like a Product fan-out root: it
|
||||
# cuts feature/main_pm/{root} per repo and opens a root->master PR per repo,
|
||||
# so the claim branch gate skips the single-branch requirement. Holds both
|
||||
# for a MegaTask root-subtask and a standalone coordination root.
|
||||
assert is_branchless_coordination(
|
||||
project_id=None, product_id=None, has_cell_projects=True
|
||||
)
|
||||
assert is_branchless_coordination(
|
||||
project_id=None,
|
||||
product_id=None,
|
||||
batch_id=uuid4(),
|
||||
parent_task_id=uuid4(),
|
||||
has_cell_projects=True,
|
||||
)
|
||||
# a cell map is NOT branchless if a project_id is also set (then it's a normal
|
||||
# project task that happens to carry a stray map — gated, not exempt).
|
||||
assert not is_branchless_coordination(
|
||||
project_id=uuid4(), product_id=None, has_cell_projects=True
|
||||
)
|
||||
|
||||
|
||||
def test_valid_batch_shape_allows_umbrella_and_root_subtask() -> None:
|
||||
bid = uuid4()
|
||||
# umbrella: batch_id, no parent, NO target
|
||||
@@ -89,3 +112,54 @@ def test_valid_batch_shape_denies_stray_batch_id() -> None:
|
||||
assert not is_valid_batch_shape(
|
||||
batch_id=bid, parent_task_id=uuid4(), project_id=uuid4(), product_id=uuid4()
|
||||
)
|
||||
|
||||
|
||||
def test_valid_batch_shape_allows_ad_hoc_cell_map_root_subtask() -> None:
|
||||
bid = uuid4()
|
||||
# a root-subtask carrying an ad-hoc per-cell map (no project, no product) is a
|
||||
# well-formed third targeting shape — exactly one of {project, product, map}.
|
||||
assert is_valid_batch_shape(
|
||||
batch_id=bid,
|
||||
parent_task_id=uuid4(),
|
||||
project_id=None,
|
||||
product_id=None,
|
||||
has_cell_projects=True,
|
||||
)
|
||||
# a non-batch task carrying a cell map is unconstrained here (the normal
|
||||
# targeting rule applies; the map is a coordination-root shape in its own
|
||||
# right, not a batch-only construct).
|
||||
assert is_valid_batch_shape(
|
||||
batch_id=None,
|
||||
parent_task_id=None,
|
||||
project_id=None,
|
||||
product_id=None,
|
||||
has_cell_projects=True,
|
||||
)
|
||||
|
||||
|
||||
def test_valid_batch_shape_denies_cell_map_alongside_another_target() -> None:
|
||||
bid = uuid4()
|
||||
# umbrella with a cell map: an umbrella must target NEITHER — a map is a target.
|
||||
assert not is_valid_batch_shape(
|
||||
batch_id=bid,
|
||||
parent_task_id=None,
|
||||
project_id=None,
|
||||
product_id=None,
|
||||
has_cell_projects=True,
|
||||
)
|
||||
# root-subtask with BOTH a project and a cell map: two targets, malformed.
|
||||
assert not is_valid_batch_shape(
|
||||
batch_id=bid,
|
||||
parent_task_id=uuid4(),
|
||||
project_id=uuid4(),
|
||||
product_id=None,
|
||||
has_cell_projects=True,
|
||||
)
|
||||
# root-subtask with BOTH a product and a cell map: two targets, malformed.
|
||||
assert not is_valid_batch_shape(
|
||||
batch_id=bid,
|
||||
parent_task_id=uuid4(),
|
||||
project_id=None,
|
||||
product_id=uuid4(),
|
||||
has_cell_projects=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Choreographer._resolve_subtask_project — fan-out resolution coverage.
|
||||
|
||||
The method resolves which project a delegated subtask lands in. It has four
|
||||
priority tiers, exercised here against a stub ``self`` (the cell-map match
|
||||
path never touches ``self``, and the raise path only reads ``self.product``):
|
||||
|
||||
1. explicit ``inputs.project_id`` wins outright.
|
||||
2. the parent's ad-hoc ``cell_projects`` map → the mapping for ``inputs.team``.
|
||||
3. the parent's Product map (delegated to ``self.product.project_for``).
|
||||
4. the parent's own ``project_id``.
|
||||
5. otherwise ``TaskCompletenessError`` (no repo to land in).
|
||||
|
||||
The ad-hoc cell-map tier (2) is the multi-cell MegaTask root-subtask seam; the
|
||||
other tiers are unchanged product-root / single-project behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.foundation.policy.task_completeness import TaskCompletenessError
|
||||
from roboco.models.base import Team
|
||||
from roboco.services.gateway.choreographer._impl import (
|
||||
Choreographer,
|
||||
DelegateInputs,
|
||||
)
|
||||
|
||||
|
||||
def _inputs(*, team: Team, project_id: UUID | None = None) -> DelegateInputs:
|
||||
return DelegateInputs(
|
||||
title="t",
|
||||
description="d",
|
||||
assigned_to="be-dev-1",
|
||||
team=team.value,
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["a"],
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
|
||||
def _mapping(team: Team, project_id: UUID) -> SimpleNamespace:
|
||||
return SimpleNamespace(team=team, project_id=project_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_inputs_project_id_wins_over_cell_map() -> None:
|
||||
"""Tier 1: an explicit project_id on delegate short-circuits the map."""
|
||||
be_proj, explicit = uuid4(), uuid4()
|
||||
parent = SimpleNamespace(
|
||||
cell_projects=[_mapping(Team.BACKEND, be_proj)],
|
||||
product_id=None,
|
||||
project_id=None,
|
||||
)
|
||||
self_stub = SimpleNamespace(product=None)
|
||||
resolved = await Choreographer._resolve_subtask_project(
|
||||
self_stub, parent, _inputs(team=Team.BACKEND, project_id=explicit)
|
||||
)
|
||||
assert resolved == explicit
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cell_map_resolves_project_for_matching_team() -> None:
|
||||
"""Tier 2: the parent's cell map yields the project for the delegated cell."""
|
||||
be_proj, fe_proj = uuid4(), uuid4()
|
||||
parent = SimpleNamespace(
|
||||
cell_projects=[
|
||||
_mapping(Team.FRONTEND, fe_proj),
|
||||
_mapping(Team.BACKEND, be_proj),
|
||||
],
|
||||
product_id=None,
|
||||
project_id=None,
|
||||
)
|
||||
self_stub = SimpleNamespace(product=None)
|
||||
resolved = await Choreographer._resolve_subtask_project(
|
||||
self_stub, parent, _inputs(team=Team.BACKEND)
|
||||
)
|
||||
assert resolved == be_proj
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cell_map_missing_team_falls_through_to_parent_project() -> None:
|
||||
"""No mapping for the requested cell → fall through to the parent's own
|
||||
project_id (tier 4), not raise — the parent may still carry a project."""
|
||||
be_proj, own = uuid4(), uuid4()
|
||||
parent = SimpleNamespace(
|
||||
cell_projects=[_mapping(Team.BACKEND, be_proj)],
|
||||
product_id=None,
|
||||
project_id=own,
|
||||
)
|
||||
self_stub = SimpleNamespace(product=None)
|
||||
# Frontend subtask but the map only covers backend → fall to parent.project_id.
|
||||
resolved = await Choreographer._resolve_subtask_project(
|
||||
self_stub, parent, _inputs(team=Team.FRONTEND)
|
||||
)
|
||||
assert resolved == own
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cell_map_only_parent_with_no_match_raises_completeness() -> None:
|
||||
"""A fan-out parent (cell map, no own project, no product) with no mapping
|
||||
for the requested cell raises TaskCompletenessError — the subtask has no
|
||||
repo to land in."""
|
||||
be_proj = uuid4()
|
||||
parent = SimpleNamespace(
|
||||
cell_projects=[_mapping(Team.BACKEND, be_proj)],
|
||||
product_id=None,
|
||||
project_id=None,
|
||||
)
|
||||
self_stub = SimpleNamespace(product=None)
|
||||
with pytest.raises(TaskCompletenessError) as exc:
|
||||
await Choreographer._resolve_subtask_project(
|
||||
self_stub, parent, _inputs(team=Team.FRONTEND)
|
||||
)
|
||||
assert "project_id" in exc.value.missing
|
||||
@@ -32,6 +32,7 @@ from roboco.services.base import ServiceError, ValidationError
|
||||
from roboco.services.prompter import (
|
||||
PrompterService,
|
||||
_cell_teams,
|
||||
_draft_cell_map,
|
||||
compose_description,
|
||||
derive_scale,
|
||||
get_prompter_service,
|
||||
@@ -102,6 +103,7 @@ def test_derive_scale_single_vs_multi() -> None:
|
||||
# "'str' object has no attribute 'get'").
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cell_teams_tolerates_bare_string_entries() -> None:
|
||||
# The LLM emitted the_work as a list of team names, not objects.
|
||||
assert _cell_teams(["backend", "frontend", "backend"]) == ["backend", "frontend"]
|
||||
@@ -697,3 +699,193 @@ def test_preview_batch_tolerates_bare_string_the_work() -> None:
|
||||
result = service.preview_batch(drafts)
|
||||
assert isinstance(result["waves"], list)
|
||||
assert isinstance(result["warnings"], list)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Per-cell project map (multi-cell MegaTask root-subtask seam) — pure helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _work(team: str, project_id: UUID | None) -> dict[str, Any]:
|
||||
entry: dict[str, Any] = {"team": team, "summary": "s", "items": ["x"]}
|
||||
if project_id is not None:
|
||||
entry["project_id"] = str(project_id)
|
||||
return entry
|
||||
|
||||
|
||||
def test_draft_cell_map_collects_per_cell_projects_in_order() -> None:
|
||||
"""A multi-cell draft yields one (team, project_id) per the_work entry,
|
||||
in the_work order, de-duped by team."""
|
||||
be_proj, fe_proj = uuid4(), uuid4()
|
||||
draft = {
|
||||
"the_work": [
|
||||
_work("backend", be_proj),
|
||||
_work("frontend", fe_proj),
|
||||
]
|
||||
}
|
||||
assert _draft_cell_map(draft) == [(Team.BACKEND, be_proj), (Team.FRONTEND, fe_proj)]
|
||||
|
||||
|
||||
def test_draft_cell_map_dedupes_repeated_team_keeping_first() -> None:
|
||||
"""Two entries for the same cell (LLM noise) keep the first mapping — a
|
||||
task_cell_projects row is unique per (task, team)."""
|
||||
first, second = uuid4(), uuid4()
|
||||
draft = {
|
||||
"the_work": [
|
||||
_work("backend", first),
|
||||
_work("backend", second),
|
||||
]
|
||||
}
|
||||
assert _draft_cell_map(draft) == [(Team.BACKEND, first)]
|
||||
|
||||
|
||||
def test_draft_cell_map_skips_entries_without_project_id() -> None:
|
||||
"""An entry with no project_id (single-cell legacy or a bare team string) is
|
||||
skipped — the draft then falls back to its top-level project_id."""
|
||||
be_proj = uuid4()
|
||||
draft = {
|
||||
"the_work": [
|
||||
_work("backend", be_proj),
|
||||
{"team": "frontend", "summary": "s", "items": []}, # no project_id
|
||||
]
|
||||
}
|
||||
assert _draft_cell_map(draft) == [(Team.BACKEND, be_proj)]
|
||||
|
||||
|
||||
def test_draft_cell_map_empty_when_no_entry_has_project_id() -> None:
|
||||
"""A legacy single-cell draft (top-level project_id, bare-string the_work)
|
||||
yields an empty map — the caller falls back to the top-level project_id."""
|
||||
assert _draft_cell_map({"the_work": ["backend", "frontend"]}) == []
|
||||
assert _draft_cell_map({"the_work": [{"team": "backend"}]}) == []
|
||||
|
||||
|
||||
def test_draft_cell_map_ignores_off_enum_teams_and_bad_uuids() -> None:
|
||||
"""Off-enum team names and malformed project_ids are skipped, not crashed on
|
||||
(the intake agent is an LLM)."""
|
||||
good = uuid4()
|
||||
draft = {
|
||||
"the_work": [
|
||||
_work("backend", good),
|
||||
{"team": "marketing", "project_id": str(uuid4())}, # not a cell
|
||||
_work("frontend", None), # missing
|
||||
{"team": "ux_ui", "project_id": "not-a-uuid"}, # bad uuid
|
||||
]
|
||||
}
|
||||
assert _draft_cell_map(draft) == [(Team.BACKEND, good)]
|
||||
|
||||
|
||||
def test_validate_batch_scope_accepts_single_multi_cell_draft() -> None:
|
||||
"""One 2-cell draft already spans ≥2 distinct projects → valid MegaTask."""
|
||||
be_proj, fe_proj = uuid4(), uuid4()
|
||||
drafts = [
|
||||
{
|
||||
"title": "S1",
|
||||
"acceptance_criteria": ["a"],
|
||||
"the_work": [
|
||||
_work("backend", be_proj),
|
||||
_work("frontend", fe_proj),
|
||||
],
|
||||
}
|
||||
]
|
||||
# Must not raise: 2 distinct projects across the one draft's cells.
|
||||
PrompterService._validate_batch_scope(drafts, [be_proj, fe_proj])
|
||||
|
||||
|
||||
def test_validate_batch_scope_rejects_out_of_scope_per_cell_project() -> None:
|
||||
"""A per-cell project_id outside the scoped set is refused."""
|
||||
in_scope, out_of_scope = uuid4(), uuid4()
|
||||
drafts = [
|
||||
{
|
||||
"title": "S1",
|
||||
"acceptance_criteria": ["a"],
|
||||
"the_work": [
|
||||
_work("backend", in_scope),
|
||||
_work("frontend", out_of_scope),
|
||||
],
|
||||
}
|
||||
]
|
||||
with pytest.raises(ValidationError, match="outside this MegaTask"):
|
||||
PrompterService._validate_batch_scope(drafts, [in_scope, uuid4()])
|
||||
|
||||
|
||||
def test_validate_batch_scope_rejects_draft_with_no_project() -> None:
|
||||
"""A draft with neither a per-cell map nor a top-level project_id is refused."""
|
||||
drafts = [
|
||||
{
|
||||
"title": "S1",
|
||||
"acceptance_criteria": ["a"],
|
||||
"the_work": [_work("backend", None), _work("frontend", None)],
|
||||
}
|
||||
]
|
||||
with pytest.raises(ValidationError, match="has no project"):
|
||||
PrompterService._validate_batch_scope(drafts, [uuid4(), uuid4()])
|
||||
|
||||
|
||||
def test_validate_batch_scope_distinct_count_spans_all_cells() -> None:
|
||||
"""The ≥2 minimum counts distinct projects across ALL drafts' cells, not per
|
||||
draft. Two single-cell drafts on the same project still fail (degenerate)."""
|
||||
only = uuid4()
|
||||
drafts = [
|
||||
{
|
||||
"title": "A",
|
||||
"acceptance_criteria": ["a"],
|
||||
"the_work": [_work("backend", only)],
|
||||
},
|
||||
{
|
||||
"title": "B",
|
||||
"acceptance_criteria": ["b"],
|
||||
"the_work": [_work("frontend", only)], # same project, different cell
|
||||
},
|
||||
]
|
||||
with pytest.raises(ValidationError, match="at least two distinct projects"):
|
||||
PrompterService._validate_batch_scope(drafts, [only, uuid4()])
|
||||
|
||||
|
||||
def test_validate_batch_scope_legacy_single_cell_drafts_still_work() -> None:
|
||||
"""Back-compat: drafts using a top-level project_id (no the_work map) still
|
||||
validate against the scope and the ≥2 distinct minimum."""
|
||||
p1, p2 = uuid4(), uuid4()
|
||||
drafts = [
|
||||
{"title": "A", "acceptance_criteria": ["a"], "project_id": str(p1)},
|
||||
{"title": "B", "acceptance_criteria": ["b"], "project_id": str(p2)},
|
||||
]
|
||||
PrompterService._validate_batch_scope(drafts, [p1, p2])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_owning_team_multi_cell_map_routes_to_main_pm() -> None:
|
||||
"""A multi-cell ad-hoc map is a coordination root (mirrors a product root), so
|
||||
it routes to the Main PM — never the lead cell (a cell PM can't delegate
|
||||
cross-cell; that would deadlock the fan-out). No DB access on this branch."""
|
||||
service = get_prompter_service() # no db — the cell-map branch never reads it
|
||||
be_proj, fe_proj = uuid4(), uuid4()
|
||||
draft = {
|
||||
"the_work": [
|
||||
_work("backend", be_proj),
|
||||
_work("frontend", fe_proj),
|
||||
]
|
||||
}
|
||||
team = await service._resolve_owning_team(
|
||||
draft,
|
||||
resolved_product_id=None,
|
||||
resolved_assigned_to=None,
|
||||
team_override=None,
|
||||
default_lead=Team.BACKEND,
|
||||
)
|
||||
assert team is Team.MAIN_PM
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_owning_team_single_cell_still_routes_to_lead_cell() -> None:
|
||||
"""A single-cell project draft (no product, no multi-cell map) keeps its
|
||||
legacy owner: the lead cell."""
|
||||
service = get_prompter_service()
|
||||
draft = {"the_work": [_work("backend", uuid4())]}
|
||||
team = await service._resolve_owning_team(
|
||||
draft,
|
||||
resolved_product_id=None,
|
||||
resolved_assigned_to=None,
|
||||
team_override=None,
|
||||
default_lead=Team.BACKEND,
|
||||
)
|
||||
assert team is Team.BACKEND
|
||||
|
||||
@@ -993,10 +993,66 @@ async def test_ensure_branch_coordination_root_no_cell_map_stays_branchless() ->
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_branch_raises_when_neither_project_nor_product() -> None:
|
||||
"""A task with neither a project nor a product is genuinely misconfigured."""
|
||||
async def test_ensure_branch_cell_map_root_cuts_integration_branch_per_project() -> (
|
||||
None
|
||||
):
|
||||
"""An ad-hoc cell_projects root cuts feature/main_pm/{root} in each distinct
|
||||
project the map spans — the product-root path with the map sourced from the
|
||||
task instead of a Product."""
|
||||
svc = TaskService(MagicMock())
|
||||
task = MagicMock(branch_name=None, project_id=None, product_id=None)
|
||||
be_proj, fe_proj = uuid4(), uuid4()
|
||||
cell_map = [
|
||||
SimpleNamespace(team=Team.BACKEND, project_id=be_proj),
|
||||
SimpleNamespace(team=Team.FRONTEND, project_id=fe_proj),
|
||||
]
|
||||
task = MagicMock(
|
||||
branch_name=None,
|
||||
project_id=None,
|
||||
product_id=None,
|
||||
batch_id=uuid4(),
|
||||
parent_task_id=uuid4(),
|
||||
cell_projects=cell_map,
|
||||
)
|
||||
create_in_project = AsyncMock(return_value="feature/main_pm/root1234")
|
||||
_bind(svc, "_create_branch_in_project", create_in_project)
|
||||
project_svc = MagicMock(get=AsyncMock(return_value=MagicMock()))
|
||||
with patch("roboco.services.project.get_project_service", return_value=project_svc):
|
||||
result = await svc._ensure_branch_for_task(task, uuid4())
|
||||
assert result == "feature/main_pm/root1234"
|
||||
# one integration branch per distinct project in the map (here 2 cells, 2 projects)
|
||||
assert create_in_project.await_count == len(cell_map)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_distinct_projects_for_task_dedupes_cell_map_by_project_id() -> None:
|
||||
"""Two cells mapping at the same project (the monorepo case) yield ONE
|
||||
integration branch, not two — mirroring product distinct_project_ids."""
|
||||
svc = TaskService(MagicMock())
|
||||
shared = uuid4()
|
||||
task = MagicMock(
|
||||
project_id=None,
|
||||
product_id=None,
|
||||
cell_projects=[
|
||||
SimpleNamespace(team=Team.FRONTEND, project_id=shared),
|
||||
SimpleNamespace(team=Team.BACKEND, project_id=shared),
|
||||
],
|
||||
)
|
||||
ids = await svc._distinct_projects_for_task(task)
|
||||
assert ids == [shared]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_branch_raises_when_neither_project_nor_product() -> None:
|
||||
"""A task with neither a project, a product, nor a cell map is misconfigured."""
|
||||
svc = TaskService(MagicMock())
|
||||
task = MagicMock(
|
||||
branch_name=None,
|
||||
project_id=None,
|
||||
product_id=None,
|
||||
cell_projects=[],
|
||||
batch_id=None,
|
||||
parent_task_id=None,
|
||||
)
|
||||
with pytest.raises(ValueError, match="project_id"):
|
||||
await svc._ensure_branch_for_task(task, uuid4())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user