mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(tasks): merge cross-lineage dependency content at branch cut (#466)
* fix(tasks): merge cross-lineage dependency content at branch cut The dependency gate enforced timing but never content: a dependent's fresh branch could miss a same-repo dependency's merged work when that merge landed outside the branch's own ancestor chain (cross-cell edges under one root, same-repo batch cross-root edges). After a successful branch cut, each dependency's real merge target (resolve_parent_branch) is fetched and, unless already an ancestor, merged into the new branch; conflicts abort cleanly (branch stays at its cut point, warning + an accumulating task marker note) and never fail the claim. Cross-repo dependencies are skipped — no shared history. Zero git work for the no-deps common case; resumes never re-enter (branch creation only). * chore(foundation): regenerate lifecycle artifacts; reflow inherited prose * [lineage] mypy-clean mock idioms in the lineage orchestration tests --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -1263,6 +1263,84 @@ class GitService(BaseService):
|
||||
|
||||
return branch_name, base_branch
|
||||
|
||||
async def merge_dependency_lineage(
|
||||
self,
|
||||
workspace: Path,
|
||||
task_id: UUID,
|
||||
branch_name: str,
|
||||
source_branch: str,
|
||||
project_slug: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Backfill a freshly cut branch with a dependency's merged content.
|
||||
|
||||
The claim-time dependency gate only holds a task until every
|
||||
``dependency_ids`` entry is terminal (TIMING) — it never checks
|
||||
whether that entry's merged work is actually reachable from this
|
||||
branch's base (CONTENT). A cross-subtree/cross-cell dependency edge
|
||||
can complete on a branch this one never descends from (e.g. a UX
|
||||
cell task merges into the UX cell branch; a sibling frontend cell
|
||||
task cut from the frontend cell branch has no ancestry into it until
|
||||
the UX cell itself submits up).
|
||||
|
||||
No-op when ``source_branch`` is already an ancestor of
|
||||
``branch_name`` (the common, transitively-safe case: same-parent
|
||||
siblings, same-project root waves — master/the shared ancestor
|
||||
already carries the work). On a real conflict the merge is aborted
|
||||
and the branch is left exactly at its cut point: this is a
|
||||
claim-time content assist, never a gate, so it always returns a
|
||||
status rather than raising.
|
||||
|
||||
Returns ``{"status": ...}``, one of ``already_ancestor`` /
|
||||
``missing_ref`` / ``merged`` / ``merged_push_failed`` / ``conflict``
|
||||
(the last carries ``"files"``).
|
||||
"""
|
||||
token = await self._token_for_project(project_slug)
|
||||
await self._run_git(
|
||||
workspace,
|
||||
["fetch", "origin", source_branch],
|
||||
check=False,
|
||||
token=token,
|
||||
timeout=_network_git_timeout(),
|
||||
)
|
||||
origin_ref = f"origin/{source_branch}"
|
||||
if not await self._ref_exists(workspace, origin_ref):
|
||||
return {"status": "missing_ref"}
|
||||
|
||||
worktree = self._worktree_for_task(workspace, task_id)
|
||||
await self._ensure_worktree_for_commit(workspace, worktree, branch_name)
|
||||
|
||||
ancestor = await self._run_git(
|
||||
worktree,
|
||||
["merge-base", "--is-ancestor", origin_ref, branch_name],
|
||||
check=False,
|
||||
)
|
||||
if ancestor.returncode == 0:
|
||||
return {"status": "already_ancestor"}
|
||||
|
||||
merge = await self._run_git(
|
||||
worktree, ["merge", "--no-edit", origin_ref], check=False
|
||||
)
|
||||
if merge.returncode != 0:
|
||||
return await self._abort_lineage_merge_conflict(worktree)
|
||||
|
||||
push = await self._run_git(
|
||||
worktree,
|
||||
["push", "origin", branch_name],
|
||||
check=False,
|
||||
token=token,
|
||||
timeout=_network_git_timeout(),
|
||||
)
|
||||
return {"status": "merged" if push.returncode == 0 else "merged_push_failed"}
|
||||
|
||||
async def _abort_lineage_merge_conflict(self, worktree: Path) -> dict[str, Any]:
|
||||
"""Collect conflicted files and abort a failed dependency-lineage merge."""
|
||||
conflict = await self._run_git(
|
||||
worktree, ["diff", "--name-only", "--diff-filter=U"], check=False
|
||||
)
|
||||
files = [f for f in conflict.stdout.splitlines() if f.strip()]
|
||||
await self._run_git(worktree, ["merge", "--abort"], check=False)
|
||||
return {"status": "conflict", "files": files}
|
||||
|
||||
async def create_branch_from_pr_head(
|
||||
self,
|
||||
workspace: Path,
|
||||
|
||||
@@ -389,6 +389,18 @@ def _compose_review_body(summary: str | None, issues: list[str] | None) -> str:
|
||||
return f"{body}\n\n{bullets}".strip() if body else bullets
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _LineageCutContext:
|
||||
"""Git handles for a freshly cut branch, bundled so the dependency-
|
||||
lineage merge helpers (``_apply_dependency_lineage`` /
|
||||
``_merge_one_dependency``) stay under PLR0913.
|
||||
"""
|
||||
|
||||
git_service: Any
|
||||
workspace: Path
|
||||
project: Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CompletionSnapshot:
|
||||
"""Fields copied off a TaskTable before its session detaches.
|
||||
@@ -2262,6 +2274,13 @@ class TaskService(BaseService):
|
||||
await self._remove_task_worktree(workspace, require_uuid(task.id))
|
||||
raise
|
||||
|
||||
# Best-effort, never fails the claim: backfill dependency content
|
||||
# outside this branch's own ancestor chain (the cross-subtree/
|
||||
# cross-cell dependency-lineage gap).
|
||||
await self._apply_dependency_lineage(
|
||||
task, _LineageCutContext(git_service, workspace, project)
|
||||
)
|
||||
|
||||
self.log.info(
|
||||
"Auto-created hierarchical branch",
|
||||
task_id=str(task.id),
|
||||
@@ -2286,6 +2305,98 @@ class TaskService(BaseService):
|
||||
task_id=str(task_id),
|
||||
)
|
||||
|
||||
async def _apply_dependency_lineage(
|
||||
self, task: TaskTable, ctx: _LineageCutContext
|
||||
) -> None:
|
||||
"""Merge each same-repo dependency's merged content into the freshly
|
||||
cut branch when it lies outside the branch's own ancestor chain (see
|
||||
``GitService.merge_dependency_lineage`` for the why).
|
||||
|
||||
The dependency gate (``_claim_blocked_by_dependencies``) enforces
|
||||
TIMING only — a dependency edge whose merge target isn't an ancestor
|
||||
of this branch's base still lets the claim through. Scoped to
|
||||
dependencies in the SAME repo as ``ctx.project`` — a cross-repo edge
|
||||
has no shared git history to merge, so it is silently skipped.
|
||||
Zero-cost when the task has no dependencies (the common case).
|
||||
"""
|
||||
dep_ids = list(task.dependency_ids or [])
|
||||
if not dep_ids:
|
||||
return
|
||||
for dep_id in dep_ids:
|
||||
try:
|
||||
await self._merge_one_dependency(task, ctx, dep_id)
|
||||
except Exception:
|
||||
self.log.warning(
|
||||
"dependency lineage merge errored",
|
||||
task_id=str(task.id),
|
||||
dep_id=str(dep_id),
|
||||
exc_info=True,
|
||||
)
|
||||
await self.session.flush()
|
||||
|
||||
async def _merge_one_dependency(
|
||||
self, task: TaskTable, ctx: _LineageCutContext, dep_id: Any
|
||||
) -> None:
|
||||
"""Resolve one dependency's merge target and merge it in if needed."""
|
||||
from roboco.services.gateway.merge_chain import resolve_parent_branch
|
||||
|
||||
dep_task = await self.get(UUID(str(dep_id)))
|
||||
if dep_task is None or dep_task.project_id != ctx.project.id:
|
||||
return # missing, or a cross-repo edge: no shared git history
|
||||
source_branch = await resolve_parent_branch(dep_task, self)
|
||||
branch_name = str(task.branch_name)
|
||||
if not source_branch or source_branch == branch_name:
|
||||
return
|
||||
result = await ctx.git_service.merge_dependency_lineage(
|
||||
ctx.workspace,
|
||||
require_uuid(task.id),
|
||||
branch_name,
|
||||
source_branch,
|
||||
project_slug=ctx.project.slug,
|
||||
)
|
||||
status = result.get("status")
|
||||
if status == "conflict":
|
||||
self._note_dependency_lineage_conflict(
|
||||
task, dep_task, source_branch, result
|
||||
)
|
||||
elif status not in ("already_ancestor", "merged"):
|
||||
self.log.warning(
|
||||
"dependency lineage merge incomplete",
|
||||
task_id=str(task.id),
|
||||
dep_id=str(dep_id),
|
||||
source_branch=source_branch,
|
||||
status=status,
|
||||
)
|
||||
|
||||
def _note_dependency_lineage_conflict(
|
||||
self,
|
||||
task: TaskTable,
|
||||
dep_task: TaskTable,
|
||||
source_branch: str,
|
||||
result: dict[str, Any],
|
||||
) -> None:
|
||||
"""Log + note a dependency-lineage merge conflict for human follow-up."""
|
||||
files = ", ".join(result.get("files") or []) or "unknown files"
|
||||
note = (
|
||||
f"Dependency {dep_task.id} merged into {source_branch!r}, which "
|
||||
f"conflicts with this branch ({task.branch_name!r}) in: {files}. "
|
||||
f"Merge {source_branch!r} in by hand before assembling the PR."
|
||||
)
|
||||
existing = markers.get_transition_note(task, "dependency_lineage_conflict")
|
||||
markers.set_transition_note(
|
||||
task,
|
||||
"dependency_lineage_conflict",
|
||||
f"{existing}\n{note}" if existing else note,
|
||||
)
|
||||
self.log.warning(
|
||||
"dependency lineage merge conflict",
|
||||
task_id=str(task.id),
|
||||
dep_task_id=str(dep_task.id),
|
||||
branch_name=task.branch_name,
|
||||
source_branch=source_branch,
|
||||
files=result.get("files"),
|
||||
)
|
||||
|
||||
async def _distinct_projects_for_task(self, task: TaskTable) -> list[UUID]:
|
||||
"""The distinct projects a coordination root's map spans — one
|
||||
``feature/main_pm/{root}`` integration branch each.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""``GitService.merge_dependency_lineage`` — the cross-subtree/cross-cell
|
||||
dependency-lineage gap.
|
||||
|
||||
The claim-time dependency gate (``TaskService._claim_blocked_by_dependencies``)
|
||||
only enforces TIMING — a dependency must be terminal before the dependent task
|
||||
can claim — it never checks whether the dependency's MERGED content is
|
||||
reachable from the dependent's freshly cut branch. A cross-subtree/cross-cell
|
||||
edge (e.g. a UX cell task merged into the UX cell branch, depended on by a
|
||||
sibling frontend cell task cut from the frontend cell branch) can complete on
|
||||
a branch the dependent never descends from.
|
||||
|
||||
This backfills it at branch-cut time: fetch the dependency's merge-target
|
||||
branch, no-op if it is already an ancestor of the fresh branch (the common,
|
||||
transitively-safe case), otherwise merge it in. On a real conflict the merge
|
||||
is aborted and the branch is left exactly at its cut point — a content
|
||||
assist, never a gate, so it always returns a status rather than raising.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.git import GitService
|
||||
|
||||
_WORKSPACE = Path("/tmp/fake-ws")
|
||||
_TASK_ID = uuid4()
|
||||
_WORKTREE = _WORKSPACE / ".worktrees" / str(_TASK_ID)[:8]
|
||||
_BRANCH = "feature/frontend/root--fe-cell"
|
||||
_SOURCE = "feature/ux_ui/root--ux-cell"
|
||||
_ORIGIN_SOURCE = f"origin/{_SOURCE}"
|
||||
|
||||
|
||||
def _git_service() -> GitService:
|
||||
svc = GitService.__new__(GitService)
|
||||
svc.log = MagicMock()
|
||||
return svc
|
||||
|
||||
|
||||
def _result(returncode: int = 0, stdout: str = "") -> Any:
|
||||
r = MagicMock()
|
||||
r.returncode = returncode
|
||||
r.stdout = stdout
|
||||
return r
|
||||
|
||||
|
||||
def _drive(
|
||||
svc: GitService, responses: dict[tuple[str, ...], Any]
|
||||
) -> list[tuple[Path, list[str]]]:
|
||||
"""Stub _run_git to answer by args-prefix; _ensure_worktree_for_commit +
|
||||
_token_for_project are no-ops so only merge mechanics are under test."""
|
||||
calls: list[tuple[Path, list[str]]] = []
|
||||
|
||||
async def _run(workspace: Path, args: list[str], **_kw: object) -> Any:
|
||||
calls.append((Path(workspace), list(args)))
|
||||
for prefix, resp in responses.items():
|
||||
if tuple(args[: len(prefix)]) == prefix:
|
||||
return resp
|
||||
return _result()
|
||||
|
||||
object.__setattr__(svc, "_run_git", _run)
|
||||
object.__setattr__(svc, "_token_for_project", AsyncMock(return_value="tok"))
|
||||
object.__setattr__(svc, "_ensure_worktree_for_commit", AsyncMock())
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_ancestor_is_a_no_op() -> None:
|
||||
"""source_branch already reachable from branch_name: no merge, no push —
|
||||
the common, transitively-safe case (same-parent siblings, root waves)."""
|
||||
svc = _git_service()
|
||||
calls = _drive(
|
||||
svc,
|
||||
{
|
||||
("rev-parse", "--verify", "--quiet"): _result(returncode=0),
|
||||
("merge-base", "--is-ancestor"): _result(returncode=0),
|
||||
},
|
||||
)
|
||||
|
||||
result = await svc.merge_dependency_lineage(
|
||||
_WORKSPACE, _TASK_ID, _BRANCH, _SOURCE, project_slug="roboco-api"
|
||||
)
|
||||
|
||||
assert result == {"status": "already_ancestor"}
|
||||
assert not [c for c in calls if c[1][:1] == ["merge"]], (
|
||||
"an already-ancestor source must never be merged"
|
||||
)
|
||||
assert not [c for c in calls if c[1][:1] == ["push"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_ref_short_circuits_before_touching_worktree() -> None:
|
||||
"""source_branch not on origin: no worktree touched, no merge attempted."""
|
||||
svc = _git_service()
|
||||
calls = _drive(svc, {("rev-parse", "--verify", "--quiet"): _result(returncode=1)})
|
||||
|
||||
result = await svc.merge_dependency_lineage(
|
||||
_WORKSPACE, _TASK_ID, _BRANCH, _SOURCE, project_slug="roboco-api"
|
||||
)
|
||||
|
||||
assert result == {"status": "missing_ref"}
|
||||
ensure = object.__getattribute__(svc, "_ensure_worktree_for_commit")
|
||||
ensure.assert_not_awaited()
|
||||
assert not [c for c in calls if c[1][:1] == ["merge-base"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outside_lineage_merges_and_pushes_from_the_worktree() -> None:
|
||||
"""source_branch NOT an ancestor: merged into the worktree and pushed —
|
||||
the actual gap fix (cross-cell dependency content backfilled)."""
|
||||
svc = _git_service()
|
||||
calls = _drive(
|
||||
svc,
|
||||
{
|
||||
("rev-parse", "--verify", "--quiet"): _result(returncode=0),
|
||||
("merge-base", "--is-ancestor"): _result(returncode=1),
|
||||
("merge", "--no-edit"): _result(returncode=0),
|
||||
("push", "origin"): _result(returncode=0),
|
||||
},
|
||||
)
|
||||
|
||||
result = await svc.merge_dependency_lineage(
|
||||
_WORKSPACE, _TASK_ID, _BRANCH, _SOURCE, project_slug="roboco-api"
|
||||
)
|
||||
|
||||
assert result == {"status": "merged"}
|
||||
merge_calls = [c for c in calls if c[1][:1] == ["merge"]]
|
||||
push_calls = [c for c in calls if c[1][:1] == ["push"]]
|
||||
assert merge_calls and all(ws == _WORKTREE for ws, _ in merge_calls), (
|
||||
"the merge must run in the task's worktree, not the shared clone"
|
||||
)
|
||||
assert push_calls and push_calls[0][1] == ["push", "origin", _BRANCH]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_conflict_aborts_and_reports_files() -> None:
|
||||
"""A real conflict: abort, leave the branch at its cut point, never push."""
|
||||
svc = _git_service()
|
||||
calls = _drive(
|
||||
svc,
|
||||
{
|
||||
("rev-parse", "--verify", "--quiet"): _result(returncode=0),
|
||||
("merge-base", "--is-ancestor"): _result(returncode=1),
|
||||
("merge", "--no-edit"): _result(returncode=1),
|
||||
("diff", "--name-only", "--diff-filter=U"): _result(
|
||||
stdout="src/a.py\nsrc/b.py\n"
|
||||
),
|
||||
("merge", "--abort"): _result(returncode=0),
|
||||
},
|
||||
)
|
||||
|
||||
result = await svc.merge_dependency_lineage(
|
||||
_WORKSPACE, _TASK_ID, _BRANCH, _SOURCE, project_slug="roboco-api"
|
||||
)
|
||||
|
||||
assert result == {"status": "conflict", "files": ["src/a.py", "src/b.py"]}
|
||||
assert not [c for c in calls if c[1][:1] == ["push"]], (
|
||||
"a conflicted merge must never push"
|
||||
)
|
||||
abort_calls = [c for c in calls if c[1] == ["merge", "--abort"]]
|
||||
assert abort_calls, "a failed merge must be aborted, restoring the branch"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_succeeds_but_push_fails_is_reported_distinctly() -> None:
|
||||
"""A clean local merge that can't reach origin is not silently "merged"."""
|
||||
svc = _git_service()
|
||||
_drive(
|
||||
svc,
|
||||
{
|
||||
("rev-parse", "--verify", "--quiet"): _result(returncode=0),
|
||||
("merge-base", "--is-ancestor"): _result(returncode=1),
|
||||
("merge", "--no-edit"): _result(returncode=0),
|
||||
("push", "origin"): _result(returncode=1),
|
||||
},
|
||||
)
|
||||
|
||||
result = await svc.merge_dependency_lineage(
|
||||
_WORKSPACE, _TASK_ID, _BRANCH, _SOURCE, project_slug="roboco-api"
|
||||
)
|
||||
|
||||
assert result == {"status": "merged_push_failed"}
|
||||
@@ -0,0 +1,277 @@
|
||||
"""``TaskService._apply_dependency_lineage`` — orchestration of the
|
||||
cross-subtree/cross-cell dependency-lineage gap fix at the branch-cut seam.
|
||||
|
||||
The dependency gate (``_claim_blocked_by_dependencies``) enforces TIMING —
|
||||
a task can't claim until every ``dependency_ids`` entry is terminal — but
|
||||
never CONTENT: a dependency's merged work can sit on a branch this task's
|
||||
freshly cut branch never descends from (a cross-subtree/cross-cell edge, or
|
||||
a batch cross-root edge). ``_apply_dependency_lineage`` runs right after
|
||||
branch creation, resolves each dependency's real merge target via
|
||||
``merge_chain.resolve_parent_branch``, and delegates the ancestor check +
|
||||
merge to ``GitService.merge_dependency_lineage`` (covered separately in
|
||||
``test_git_dependency_lineage_merge.py``). This is best-effort by
|
||||
construction: nothing here may ever raise back into the claim.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.services.task import TaskService, _LineageCutContext
|
||||
|
||||
_MERGE_CHAIN_RESOLVE = "roboco.services.gateway.merge_chain.resolve_parent_branch"
|
||||
_WORKSPACE = Path("/tmp/ws")
|
||||
_BRANCH = "feature/frontend/root--fe-cell"
|
||||
|
||||
|
||||
def _service() -> TaskService:
|
||||
svc = TaskService.__new__(TaskService)
|
||||
svc.log = MagicMock()
|
||||
session = MagicMock()
|
||||
session.flush = AsyncMock()
|
||||
svc.session = session
|
||||
return svc
|
||||
|
||||
|
||||
def _task(**over: Any) -> MagicMock:
|
||||
task = MagicMock(
|
||||
id=over.pop("id", uuid4()),
|
||||
project_id=over.pop("project_id", uuid4()),
|
||||
dependency_ids=over.pop("dependency_ids", []),
|
||||
orchestration_markers=over.pop("orchestration_markers", None),
|
||||
branch_name=over.pop("branch_name", _BRANCH),
|
||||
)
|
||||
for key, value in over.items():
|
||||
setattr(task, key, value)
|
||||
return task
|
||||
|
||||
|
||||
def _ctx(**over: Any) -> _LineageCutContext:
|
||||
git_service = over.pop("git_service", None) or MagicMock()
|
||||
project = over.pop("project", None) or MagicMock(id=uuid4(), slug="roboco-api")
|
||||
return _LineageCutContext(
|
||||
git_service=git_service,
|
||||
workspace=over.pop("workspace", _WORKSPACE),
|
||||
project=project,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_dependencies_does_no_work() -> None:
|
||||
"""Zero dependency_ids: no DB lookups, no git calls, no flush."""
|
||||
svc = _service()
|
||||
any_svc: Any = svc
|
||||
any_svc.get = AsyncMock()
|
||||
task = _task(dependency_ids=[])
|
||||
ctx = _ctx()
|
||||
|
||||
await svc._apply_dependency_lineage(task, ctx)
|
||||
|
||||
any_svc.get.assert_not_awaited()
|
||||
ctx.git_service.merge_dependency_lineage.assert_not_called()
|
||||
any_svc.session.flush.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_project_dependency_resolves_source_and_calls_merge() -> None:
|
||||
"""A same-repo dependency: resolve its real merge target (the branch its
|
||||
PR merged into) and hand it to GitService for the ancestor check/merge."""
|
||||
svc = _service()
|
||||
any_svc: Any = svc
|
||||
project = MagicMock(id=uuid4(), slug="roboco-api")
|
||||
dep_id = uuid4()
|
||||
dep_task = _task(id=dep_id, project_id=project.id)
|
||||
task = _task(project_id=project.id, dependency_ids=[dep_id])
|
||||
any_svc.get = AsyncMock(return_value=dep_task)
|
||||
|
||||
git_service = MagicMock()
|
||||
any_git_service: Any = git_service
|
||||
any_git_service.merge_dependency_lineage = AsyncMock(
|
||||
return_value={"status": "merged"}
|
||||
)
|
||||
ctx = _ctx(git_service=git_service, project=project)
|
||||
|
||||
with patch(
|
||||
_MERGE_CHAIN_RESOLVE,
|
||||
AsyncMock(return_value="feature/ux_ui/root--ux-cell"),
|
||||
):
|
||||
await svc._apply_dependency_lineage(task, ctx)
|
||||
|
||||
any_git_service.merge_dependency_lineage.assert_awaited_once_with(
|
||||
_WORKSPACE,
|
||||
task.id,
|
||||
_BRANCH,
|
||||
"feature/ux_ui/root--ux-cell",
|
||||
project_slug="roboco-api",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_project_dependency_is_skipped() -> None:
|
||||
"""A dependency in a DIFFERENT repo has no shared git history — skip it
|
||||
rather than attempt a meaningless cross-repo merge."""
|
||||
svc = _service()
|
||||
any_svc: Any = svc
|
||||
project = MagicMock(id=uuid4(), slug="roboco-api")
|
||||
dep_id = uuid4()
|
||||
dep_task = _task(id=dep_id, project_id=uuid4()) # different project
|
||||
task = _task(project_id=project.id, dependency_ids=[dep_id])
|
||||
any_svc.get = AsyncMock(return_value=dep_task)
|
||||
ctx = _ctx(project=project)
|
||||
|
||||
await svc._apply_dependency_lineage(task, ctx)
|
||||
|
||||
ctx.git_service.merge_dependency_lineage.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_dependency_task_is_skipped() -> None:
|
||||
"""A dependency id that no longer resolves to a task: skip, don't crash."""
|
||||
svc = _service()
|
||||
any_svc: Any = svc
|
||||
project = MagicMock(id=uuid4(), slug="roboco-api")
|
||||
task = _task(project_id=project.id, dependency_ids=[uuid4()])
|
||||
any_svc.get = AsyncMock(return_value=None)
|
||||
ctx = _ctx(project=project)
|
||||
|
||||
await svc._apply_dependency_lineage(task, ctx)
|
||||
|
||||
ctx.git_service.merge_dependency_lineage.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conflict_status_notes_transition_and_warns_but_does_not_raise() -> None:
|
||||
"""A real conflict never fails the claim: it logs + leaves a task note
|
||||
naming the dependency and its branch so a human follows up."""
|
||||
svc = _service()
|
||||
any_svc: Any = svc
|
||||
project = MagicMock(id=uuid4(), slug="roboco-api")
|
||||
dep_id = uuid4()
|
||||
dep_task = _task(id=dep_id, project_id=project.id)
|
||||
task = _task(project_id=project.id, dependency_ids=[dep_id])
|
||||
any_svc.get = AsyncMock(return_value=dep_task)
|
||||
|
||||
git_service = MagicMock()
|
||||
any_git_service: Any = git_service
|
||||
any_git_service.merge_dependency_lineage = AsyncMock(
|
||||
return_value={"status": "conflict", "files": ["src/a.py"]}
|
||||
)
|
||||
ctx = _ctx(git_service=git_service, project=project)
|
||||
|
||||
with patch(
|
||||
_MERGE_CHAIN_RESOLVE,
|
||||
AsyncMock(return_value="feature/ux_ui/root--ux-cell"),
|
||||
):
|
||||
await svc._apply_dependency_lineage(task, ctx)
|
||||
|
||||
note = markers.get_transition_note(task, "dependency_lineage_conflict")
|
||||
assert note is not None
|
||||
assert str(dep_id) in note
|
||||
assert "src/a.py" in note
|
||||
assert "feature/ux_ui/root--ux-cell" in note
|
||||
svc.log.warning.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_conflict_appends_rather_than_overwrites() -> None:
|
||||
"""Two conflicting dependencies on one branch: both surface, not just
|
||||
the last one — set_transition_note's per-event dict would otherwise
|
||||
silently drop the first."""
|
||||
svc = _service()
|
||||
any_svc: Any = svc
|
||||
project = MagicMock(id=uuid4(), slug="roboco-api")
|
||||
dep_a, dep_b = uuid4(), uuid4()
|
||||
dep_task_a = _task(id=dep_a, project_id=project.id)
|
||||
dep_task_b = _task(id=dep_b, project_id=project.id)
|
||||
task = _task(project_id=project.id, dependency_ids=[dep_a, dep_b])
|
||||
any_svc.get = AsyncMock(side_effect=[dep_task_a, dep_task_b])
|
||||
|
||||
git_service = MagicMock()
|
||||
any_git_service: Any = git_service
|
||||
any_git_service.merge_dependency_lineage = AsyncMock(
|
||||
side_effect=[
|
||||
{"status": "conflict", "files": ["a.py"]},
|
||||
{"status": "conflict", "files": ["b.py"]},
|
||||
]
|
||||
)
|
||||
ctx = _ctx(git_service=git_service, project=project)
|
||||
|
||||
with patch(
|
||||
_MERGE_CHAIN_RESOLVE,
|
||||
AsyncMock(side_effect=["feature/x/one", "feature/x/two"]),
|
||||
):
|
||||
await svc._apply_dependency_lineage(task, ctx)
|
||||
|
||||
note = markers.get_transition_note(task, "dependency_lineage_conflict")
|
||||
assert note is not None
|
||||
assert str(dep_a) in note
|
||||
assert str(dep_b) in note
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_error_is_swallowed_never_raised() -> None:
|
||||
"""This is a claim-time content assist, never a gate: any unexpected
|
||||
failure (network, resolver bug) is logged and swallowed."""
|
||||
svc = _service()
|
||||
any_svc: Any = svc
|
||||
project = MagicMock(id=uuid4(), slug="roboco-api")
|
||||
dep_id = uuid4()
|
||||
dep_task = _task(id=dep_id, project_id=project.id)
|
||||
task = _task(project_id=project.id, dependency_ids=[dep_id])
|
||||
any_svc.get = AsyncMock(return_value=dep_task)
|
||||
|
||||
git_service = MagicMock()
|
||||
any_git_service: Any = git_service
|
||||
any_git_service.merge_dependency_lineage = AsyncMock(
|
||||
side_effect=RuntimeError("boom")
|
||||
)
|
||||
ctx = _ctx(git_service=git_service, project=project)
|
||||
|
||||
with patch(_MERGE_CHAIN_RESOLVE, AsyncMock(return_value="feature/x/one")):
|
||||
await svc._apply_dependency_lineage(task, ctx)
|
||||
|
||||
svc.log.warning.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch_in_project_wires_apply_dependency_lineage() -> None:
|
||||
"""Wiring check: after a successful branch cut, _create_branch_in_project
|
||||
calls the lineage step with the CONCRETE project just resolved — correct
|
||||
both for a plain task and inside the coordination-root per-project loop
|
||||
(_ensure_coordination_root_branches calls this once per spanned repo)."""
|
||||
svc = _service()
|
||||
task = _task(branch_name=None, dependency_ids=[uuid4()])
|
||||
project = MagicMock(id=task.project_id, slug="roboco-api")
|
||||
|
||||
object.__setattr__(svc, "_resolve_parent_branch", AsyncMock(return_value=None))
|
||||
object.__setattr__(svc, "_resolve_team_dir", MagicMock(return_value="backend"))
|
||||
|
||||
git_service = MagicMock()
|
||||
any_git_service: Any = git_service
|
||||
any_git_service.get_workspace = AsyncMock(return_value=_WORKSPACE)
|
||||
any_git_service.create_branch = AsyncMock(return_value=("feature/x", "master"))
|
||||
|
||||
applied: list[tuple[Any, _LineageCutContext]] = []
|
||||
|
||||
async def _fake_apply(t: Any, ctx: _LineageCutContext) -> None:
|
||||
applied.append((t, ctx))
|
||||
|
||||
object.__setattr__(svc, "_apply_dependency_lineage", _fake_apply)
|
||||
|
||||
with patch(
|
||||
"roboco.services.git.get_git_service", MagicMock(return_value=git_service)
|
||||
):
|
||||
out = await svc._create_branch_in_project(task, uuid4(), project)
|
||||
|
||||
assert out == "feature/x"
|
||||
assert len(applied) == 1
|
||||
got_task, got_ctx = applied[0]
|
||||
assert got_task is task
|
||||
assert got_ctx.git_service is git_service
|
||||
assert got_ctx.workspace == _WORKSPACE
|
||||
assert got_ctx.project is project
|
||||
Reference in New Issue
Block a user