mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[feature] i_am_done behind-base submit gate (Phase B2)
A sibling's PR merging into the parent branch while a dev worked leaves the
dev's branch behind its base — the assembled PR then can't merge cleanly and
the sibling's changes go missing (the 2026-06-27 out-of-order dev-task break).
The behind-base gate refuses i_am_done in that state and steers the dev to
sync_branch (the Phase B1 gate-level rebase verb).
- GitService.is_behind_base: rev-list --left-right --count across
origin/{base}...origin/{head} → (behind, ahead); fetch-first so origin
reflects the pushed head. Raises on git failure (consistent with
rebase_onto_base); malformed stdout degrades to (0,0).
- Choreographer._behind_base_gate: wired into _i_am_done_gate after
_ensure_branch_pushed. behind>0 → invalid_state remediate→sync_branch.
Fail-open on git/base-resolution error (flaky fetch can't strand a task at
the submit gate — the merge layer has its own behind checks). Skipped for
branchless roots and protected bases (master/main/-prefixed).
Tests: gate (6: refuse+steer/up-to-date/branchless/protected/fail-open-base/
fail-open-git), is_behind_base (6: parse/up-to-date/malformed/argv-form/
requires-branch/missing-project). ruff + mypy roboco/ tests/ clean; unit green.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
"""Unit tests for ``GitService.is_behind_base`` (multi-level sequencing Phase B2).
|
||||
|
||||
Pins the rev-list ``--left-right --count`` parsing: ``"<left> <right>"`` where
|
||||
left = commits only in the base (what the head is BEHIND by) and right = the
|
||||
head's own ahead work. The behind-base submit gate keys off the ``behind``
|
||||
count. Guards: requires ``branch_name``; raises when the project lookup misses.
|
||||
"""
|
||||
|
||||
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.base import NotFoundError
|
||||
from roboco.services.git import GitService
|
||||
|
||||
_WORKSPACE = Path("/tmp/fake-ws")
|
||||
_TOKEN = "ghp_fake"
|
||||
_BASE = "feature/backend/parent12345"
|
||||
_HEAD = "feature/backend/abc12345"
|
||||
|
||||
|
||||
def _git_service() -> GitService:
|
||||
svc = GitService.__new__(GitService)
|
||||
svc.log = MagicMock()
|
||||
return svc
|
||||
|
||||
|
||||
def _result(stdout: str = "", returncode: int = 0) -> Any:
|
||||
r = MagicMock()
|
||||
r.stdout = stdout
|
||||
r.returncode = returncode
|
||||
r.stderr = ""
|
||||
return r
|
||||
|
||||
|
||||
def _task(branch: str | None = _HEAD) -> Any:
|
||||
return MagicMock(id=uuid4(), branch_name=branch)
|
||||
|
||||
|
||||
def _project() -> Any:
|
||||
return MagicMock(slug="roboco")
|
||||
|
||||
|
||||
async def _wire(svc: GitService, *, rev_list_stdout: str) -> AsyncMock:
|
||||
"""Stub the workspace/token resolution + _run_git; return the run mock."""
|
||||
svc._project_for_task = AsyncMock(return_value=_project()) # type: ignore[method-assign]
|
||||
svc._resolve_workspace_agent_id = MagicMock(return_value=uuid4()) # type: ignore[method-assign]
|
||||
svc.get_workspace = AsyncMock(return_value=_WORKSPACE) # type: ignore[method-assign]
|
||||
svc._get_project_token_or_raise = AsyncMock(return_value=_TOKEN) # type: ignore[method-assign]
|
||||
run = AsyncMock(side_effect=[_result(), _result(stdout=rev_list_stdout)])
|
||||
svc._run_git = run # type: ignore[method-assign]
|
||||
return run
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_behind_base_parses_left_right_counts() -> None:
|
||||
"""'3 2' → behind=3, ahead=2 (3 commits on base not on head)."""
|
||||
svc = _git_service()
|
||||
await _wire(svc, rev_list_stdout="3 2")
|
||||
|
||||
behind, ahead = await svc.is_behind_base(_task(), base_branch=_BASE)
|
||||
|
||||
assert (behind, ahead) == (3, 2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_behind_base_up_to_date_returns_zeros() -> None:
|
||||
"""'0 5' → not behind (0), 5 commits ahead."""
|
||||
svc = _git_service()
|
||||
await _wire(svc, rev_list_stdout="0 5")
|
||||
|
||||
behind, ahead = await svc.is_behind_base(_task(), base_branch=_BASE)
|
||||
|
||||
assert (behind, ahead) == (0, 5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_behind_base_malformed_stdout_fails_open_to_zero() -> None:
|
||||
"""A non-numeric stdout (git error text) must not crash — degrade to (0, 0)."""
|
||||
svc = _git_service()
|
||||
await _wire(svc, rev_list_stdout="fatal: bad rev")
|
||||
|
||||
behind, ahead = await svc.is_behind_base(_task(), base_branch=_BASE)
|
||||
|
||||
assert (behind, ahead) == (0, 0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_behind_base_uses_left_right_triple_dot_form() -> None:
|
||||
"""The rev-list call must use --left-right --count with triple-dot (symmetric
|
||||
difference) across origin/{base}...origin/{head}."""
|
||||
svc = _git_service()
|
||||
run = await _wire(svc, rev_list_stdout="1 4")
|
||||
|
||||
await svc.is_behind_base(_task(), base_branch=_BASE)
|
||||
|
||||
# second _run_git call is the rev-list; assert its argv shape.
|
||||
rev_list_call = run.call_args_list[1]
|
||||
argv = rev_list_call.args[1]
|
||||
assert argv[:3] == ["rev-list", "--left-right", "--count"]
|
||||
assert argv[3] == f"origin/{_BASE}...origin/{_HEAD}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_behind_base_requires_branch_name() -> None:
|
||||
"""A branchless task has nothing to compare — ValueError, not a silent (0,0)."""
|
||||
svc = _git_service()
|
||||
with pytest.raises(ValueError, match="branch_name"):
|
||||
await svc.is_behind_base(_task(branch=None), base_branch=_BASE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_behind_base_raises_when_project_missing() -> None:
|
||||
svc = _git_service()
|
||||
svc._project_for_task = AsyncMock(return_value=None) # type: ignore[method-assign]
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
await svc.is_behind_base(_task(), base_branch=_BASE)
|
||||
Reference in New Issue
Block a user