mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Fix the PR-divergence respawn loop: loop gate, CEO god-mode, PR conflict resolver, sequence-ordered merge (#164)
* fix(orchestrator,panel): bound the respawn loop gate and give the CEO a status override
The PM respawn loop gate could never fire on a recurring tracing_gap: every
same-status respawn that emitted a tracing_gap reset the strike counter, so a
task whose unblock can never satisfy its decision gate respawned forever. Cap
the number of tracing_gap resets (pm_respawn_max_tracing_resets) so strikes
accrue once a gap is clearly recurring rather than progressing, and route the
pm-review and blocker dispatch respawn paths through the gate so it actually
applies to those loops.
Panel: the task status dropdown was driven solely by the lifecycle graph, so a
task wedged in a terminal/blocked state offered no actionable transitions. Add
an audited admin status override (PATCH status -> admin_set_status) for every
non-in-band target, letting the human operator force any state.
* feat(git): add rebase_onto_base and close_pull_request PR-divergence primitives
Agents had no way to resolve a PR that could not merge because a sibling merged
overlapping work first: their only moves were complete (which 405s) or block
(which loops). Add the two missing operations:
- rebase_onto_base rebases a head branch onto the latest base and classifies
the outcome: superseded (no unique commits -> safe to close), rebased (unique
work -> force-pushed, ready to merge), or conflicts (aborted, needs a human).
- close_pull_request retires a superseded PR with an explanatory comment.
These back both the sequence-ordered merge and the conflict resolver.
* feat(gateway): auto-resolve a leaf PR that can't merge instead of looping
When a sibling lands overlapping work first, the cell PM's complete() merge
hits a GitHub 405 and the task re-blocks, respawning the PM forever (the
production wedge: one task burned 6000+ tool calls over 3 hours). The merge
now raises MergeConflictError, and cell_pm_complete resolves it:
- rebase the branch onto the current base;
- superseded (no unique commits) -> close the dead PR + complete the task
without a redundant merge (the manual action operators kept requesting);
- rebased (unique work) -> retry the merge, then complete;
- genuine conflicts -> admin-override the task to awaiting_ceo_approval and
alert the CEO, so it leaves agent dispatch instead of looping.
MergeConflictError subclasses GitError, so existing handlers are unaffected.
* test(git): silence unused-arg lint in close_pull_request stub
* feat(orchestrator): sequence-ordered merge for leaf siblings
Leaf siblings share one cell branch, but within-cell siblings were all left at
the default sequence 0, so two leaf PRs raced into the same branch and the
second wedged. Now:
- decomposition assigns each new sibling the next ordinal within its parent, so
the merge order is well-defined;
- the pm-review dispatcher holds a higher-sequence leaf until its earlier
same-team siblings are terminal, so they merge into the shared branch in order
instead of racing.
Loop-free by construction: a gated task is simply not dispatched this tick (no
reject, no respawn). Terminal siblings never block, so a cancelled sibling can't
deadlock the rest; any sibling lookup failure degrades to dispatch.
* test: use monkeypatch.setattr instead of type:ignore in new tests
CI type-checks tests/ (the type-gated suite) which my local 'mypy roboco/' skipped.
The method-mock assignments tripped mypy method-assign/assignment; replace the
silencing comments with monkeypatch.setattr and local mock refs for assertions,
matching the project's no-type:ignore rule.
* fix(git): stop get_status misreporting an unstaged deletion as staged
git_status used stdout.strip().split() before parsing porcelain. strip() eats
the leading space on the first line, so an unstaged deletion (' D file') became
'D file' and parsed as a STAGED deletion — the false 'staged' that caused 6
wasted QA cycles when a dev deleted a file without staging it. Use splitlines(),
which preserves the index/worktree status columns.
* feat(panel): mobile sidebar hamburger + Sheet drawer (AC1)
The umbrella's AC1 was never built: on mobile the sidebar had no entry point.
Extract the nav/footer into shared SidebarNav/SidebarFooter, hide the static
sidebar below md, and add a hamburger in the header that opens the same nav in a
left Sheet drawer (closing on navigation). Desktop is unchanged.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
"""Conflict resolver: a leaf PR that can't merge is resolved, never looped.
|
||||
|
||||
When a sibling lands overlapping work first, ``cell_pm_complete``'s merge
|
||||
raises ``MergeConflictError``. The resolver rebases and acts on the outcome:
|
||||
re-merge (rebased), close + complete (superseded), or escalate to the CEO
|
||||
(genuine conflicts) — instead of failing back into a respawn loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.exceptions import MergeConflictError
|
||||
from roboco.models.base import TaskStatus
|
||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||||
|
||||
|
||||
def _make_deps(**overrides: AsyncMock) -> ChoreographerDeps:
|
||||
task = overrides.get("task", AsyncMock())
|
||||
git = overrides.get("git", AsyncMock())
|
||||
evidence_repo = overrides.get("evidence_repo", AsyncMock())
|
||||
return ChoreographerDeps(
|
||||
task=task,
|
||||
work_session=overrides.get("work_session", AsyncMock()),
|
||||
git=git,
|
||||
a2a=overrides.get("a2a", AsyncMock()),
|
||||
journal=overrides.get("journal", AsyncMock()),
|
||||
audit=overrides.get("audit", AsyncMock()),
|
||||
evidence_repo=evidence_repo,
|
||||
)
|
||||
|
||||
|
||||
def _choreo(
|
||||
task: AsyncMock, git: AsyncMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> Choreographer:
|
||||
choreo = Choreographer(_make_deps(task=task, git=git))
|
||||
# Isolate from briefing assembly (hits many repos) and CEO notification.
|
||||
# monkeypatch.setattr (not direct assignment) keeps mypy's method-assign
|
||||
# check satisfied without silencing it.
|
||||
monkeypatch.setattr(choreo, "_briefing_for", AsyncMock(return_value={}))
|
||||
monkeypatch.setattr(choreo, "_notify_ceo_merge_conflict", AsyncMock())
|
||||
monkeypatch.setattr(choreo, "_maybe_advance_parent_to_pm_review", AsyncMock())
|
||||
return choreo
|
||||
|
||||
|
||||
_EXC = MergeConflictError("GitHub API refused PR merge (405): not mergeable")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_superseded_closes_pr_and_completes_without_merge(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""All work already in base -> close the dead PR + complete, no re-merge."""
|
||||
git = AsyncMock()
|
||||
git.rebase_pr_for_task = AsyncMock(return_value={"status": "superseded"})
|
||||
git.close_pull_request = AsyncMock()
|
||||
git.pr_merge = AsyncMock()
|
||||
task = AsyncMock()
|
||||
task.cell_pm_complete = AsyncMock(
|
||||
return_value=MagicMock(status="completed", parent_task_id=None, team="frontend")
|
||||
)
|
||||
choreo = _choreo(task, git, monkeypatch)
|
||||
t = MagicMock(pr_number=159, parent_task_id=None, team="frontend")
|
||||
|
||||
env = await choreo._resolve_merge_conflict_on_complete(
|
||||
uuid4(), uuid4(), t, "feature/frontend/root--cell", "notes", _EXC
|
||||
)
|
||||
|
||||
git.close_pull_request.assert_awaited_once()
|
||||
task.cell_pm_complete.assert_awaited_once()
|
||||
# No redundant merge for a superseded branch.
|
||||
git.pr_merge.assert_not_awaited()
|
||||
# Completed without a merge commit.
|
||||
assert task.cell_pm_complete.await_args.kwargs["merge_commit"] is None
|
||||
assert env.error is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebased_retries_merge_and_completes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Clean rebase with unique work -> retry the merge, then complete."""
|
||||
git = AsyncMock()
|
||||
git.rebase_pr_for_task = AsyncMock(
|
||||
return_value={"status": "rebased", "unique_commits": 2}
|
||||
)
|
||||
git.pr_merge = AsyncMock(return_value={"merge_commit_sha": "abc123"})
|
||||
git.close_pull_request = AsyncMock()
|
||||
task = AsyncMock()
|
||||
task.cell_pm_complete = AsyncMock(
|
||||
return_value=MagicMock(status="completed", parent_task_id=None, team="frontend")
|
||||
)
|
||||
choreo = _choreo(task, git, monkeypatch)
|
||||
t = MagicMock(pr_number=160, parent_task_id=None, team="backend")
|
||||
|
||||
await choreo._resolve_merge_conflict_on_complete(
|
||||
uuid4(), uuid4(), t, "feature/backend/root--cell", "notes", _EXC
|
||||
)
|
||||
|
||||
git.pr_merge.assert_awaited_once()
|
||||
git.close_pull_request.assert_not_awaited()
|
||||
assert task.cell_pm_complete.await_args.kwargs["merge_commit"] == "abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_genuine_conflict_escalates_to_ceo_and_does_not_loop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Unresolvable conflict -> admin override to awaiting_ceo_approval."""
|
||||
git = AsyncMock()
|
||||
git.rebase_pr_for_task = AsyncMock(
|
||||
return_value={"status": "conflicts", "files": ["src/a.py", "src/b.py"]}
|
||||
)
|
||||
git.close_pull_request = AsyncMock()
|
||||
git.pr_merge = AsyncMock()
|
||||
task = AsyncMock()
|
||||
task.admin_set_status = AsyncMock()
|
||||
task.get = AsyncMock(return_value=MagicMock(status="awaiting_ceo_approval"))
|
||||
choreo = _choreo(task, git, monkeypatch)
|
||||
# Hold a local ref to the notification mock so the assertion has a typed
|
||||
# AsyncMock to call (the attribute itself reads as the original method type).
|
||||
notify = AsyncMock()
|
||||
monkeypatch.setattr(choreo, "_notify_ceo_merge_conflict", notify)
|
||||
tid = uuid4()
|
||||
t = MagicMock(pr_number=160, parent_task_id=None, team="backend")
|
||||
|
||||
env = await choreo._resolve_merge_conflict_on_complete(
|
||||
uuid4(), tid, t, "feature/backend/root--cell", "notes", _EXC
|
||||
)
|
||||
|
||||
task.admin_set_status.assert_awaited_once()
|
||||
args = task.admin_set_status.await_args.args
|
||||
assert args[0] == tid
|
||||
assert args[1] == TaskStatus.AWAITING_CEO_APPROVAL
|
||||
# Never closes or re-merges a branch with real unresolved conflicts.
|
||||
git.close_pull_request.assert_not_awaited()
|
||||
git.pr_merge.assert_not_awaited()
|
||||
task.cell_pm_complete.assert_not_awaited()
|
||||
notify.assert_awaited_once()
|
||||
assert env.error is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_rebase_outcome_escalates_rather_than_completing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A non-classifiable rebase result must escalate, not silently complete."""
|
||||
git = AsyncMock()
|
||||
git.rebase_pr_for_task = AsyncMock(return_value={"status": "unknown"})
|
||||
git.close_pull_request = AsyncMock()
|
||||
git.pr_merge = AsyncMock()
|
||||
task = AsyncMock()
|
||||
task.admin_set_status = AsyncMock()
|
||||
task.get = AsyncMock(return_value=MagicMock(status="awaiting_ceo_approval"))
|
||||
choreo = _choreo(task, git, monkeypatch)
|
||||
t = MagicMock(pr_number=160, parent_task_id=None, team="backend")
|
||||
|
||||
await choreo._resolve_merge_conflict_on_complete(
|
||||
uuid4(), uuid4(), t, "feature/backend/root--cell", "notes", _EXC
|
||||
)
|
||||
|
||||
task.admin_set_status.assert_awaited_once()
|
||||
task.cell_pm_complete.assert_not_awaited()
|
||||
git.close_pull_request.assert_not_awaited()
|
||||
@@ -65,6 +65,40 @@ async def test_three_tracing_gap_responses_do_not_trip_kill() -> None:
|
||||
assert fake_audit.has_recent_tracing_gap.await_count >= expected_audit_calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unending_tracing_gap_is_bounded_and_eventually_trips() -> None:
|
||||
"""A task whose EVERY respawn trips the same tracing_gap must still die.
|
||||
|
||||
The rule-following reset is bounded by ``pm_respawn_max_tracing_resets``.
|
||||
Before this bound a permanently-wedged task (e.g. a cold-respawned PM that
|
||||
can never satisfy the unblock journal-decision gate) emitted a tracing_gap
|
||||
on every spawn, reset the strike counter every time, and respawned
|
||||
forever — the production bleed. With the cap, resets are exhausted and
|
||||
strikes accrue until the gate fires.
|
||||
"""
|
||||
orch = _new_orchestrator()
|
||||
task_id = str(uuid4())
|
||||
task = {"id": task_id, "status": "blocked"}
|
||||
|
||||
fake_audit = AsyncMock()
|
||||
fake_audit.has_recent_tracing_gap = AsyncMock(return_value=True)
|
||||
|
||||
with (
|
||||
patch("roboco.services.audit.get_audit_service", return_value=fake_audit),
|
||||
patch(
|
||||
"roboco.services.notification.NotificationService",
|
||||
return_value=AsyncMock(),
|
||||
),
|
||||
):
|
||||
results = [
|
||||
await orch._pm_respawn_should_gate("main-pm", task) for _ in range(12)
|
||||
]
|
||||
|
||||
# The bug was that this list would be all-False forever. The gate must
|
||||
# trip at least once now that the reset budget is finite.
|
||||
assert any(results), "tracing_gap loop must eventually be gated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_three_no_progress_spawns_still_trip_kill() -> None:
|
||||
"""When there is NO tracing_gap envelope, the strike logic still bites.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Sequence-ordered merge: hold a later sibling's review until earlier ones land.
|
||||
|
||||
Leaf siblings share one cell branch, so merging a higher-sequence sibling before
|
||||
a lower one diverges the branch and wedges the loser. The dispatcher skips a
|
||||
higher-sequence task while an earlier same-team sibling is still non-terminal —
|
||||
loop-free (not dispatched, not rejected). Terminal siblings never block, so a
|
||||
cancelled sibling can't deadlock the rest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.models.base import TaskStatus
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
|
||||
def _new_orchestrator() -> AgentOrchestrator:
|
||||
return AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
|
||||
|
||||
def _sibling(seq: int, team: str, status: TaskStatus) -> MagicMock:
|
||||
return MagicMock(id=uuid4(), sequence=seq, team=team, status=status)
|
||||
|
||||
|
||||
def _patch_siblings(siblings: list[MagicMock]) -> Any:
|
||||
"""Patch the orchestrator's direct-DB sibling lookup to return ``siblings``."""
|
||||
svc = MagicMock()
|
||||
svc.get_subtasks = AsyncMock(return_value=siblings)
|
||||
|
||||
class _CM:
|
||||
async def __aenter__(self) -> MagicMock:
|
||||
return MagicMock()
|
||||
|
||||
async def __aexit__(self, *_a: Any) -> bool:
|
||||
return False
|
||||
|
||||
factory = MagicMock(return_value=_CM())
|
||||
return (
|
||||
patch("roboco.db.base.get_session_factory", return_value=factory),
|
||||
patch("roboco.services.task.get_task_service", return_value=svc),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocks_when_earlier_same_team_sibling_active() -> None:
|
||||
orch = _new_orchestrator()
|
||||
task = {
|
||||
"id": str(uuid4()),
|
||||
"parent_task_id": str(uuid4()),
|
||||
"sequence": 1,
|
||||
"team": "frontend",
|
||||
}
|
||||
siblings = [_sibling(0, "frontend", TaskStatus.IN_PROGRESS)]
|
||||
p1, p2 = _patch_siblings(siblings)
|
||||
with p1, p2:
|
||||
assert await orch._blocked_by_earlier_sibling(task) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_blocked_when_earlier_sibling_terminal() -> None:
|
||||
orch = _new_orchestrator()
|
||||
task = {
|
||||
"id": str(uuid4()),
|
||||
"parent_task_id": str(uuid4()),
|
||||
"sequence": 1,
|
||||
"team": "frontend",
|
||||
}
|
||||
# Earlier sibling completed -> no longer blocks. (Cancelled likewise.)
|
||||
siblings = [
|
||||
_sibling(0, "frontend", TaskStatus.COMPLETED),
|
||||
_sibling(0, "frontend", TaskStatus.CANCELLED),
|
||||
]
|
||||
p1, p2 = _patch_siblings(siblings)
|
||||
with p1, p2:
|
||||
assert await orch._blocked_by_earlier_sibling(task) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_blocked_by_different_team_sibling() -> None:
|
||||
orch = _new_orchestrator()
|
||||
task = {
|
||||
"id": str(uuid4()),
|
||||
"parent_task_id": str(uuid4()),
|
||||
"sequence": 1,
|
||||
"team": "frontend",
|
||||
}
|
||||
# A backend sibling targets a different branch — never blocks the frontend leaf.
|
||||
siblings = [_sibling(0, "backend", TaskStatus.IN_PROGRESS)]
|
||||
p1, p2 = _patch_siblings(siblings)
|
||||
with p1, p2:
|
||||
assert await orch._blocked_by_earlier_sibling(task) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_higher_sequence_sibling_does_not_block() -> None:
|
||||
orch = _new_orchestrator()
|
||||
task = {
|
||||
"id": str(uuid4()),
|
||||
"parent_task_id": str(uuid4()),
|
||||
"sequence": 0,
|
||||
"team": "frontend",
|
||||
}
|
||||
# A LATER sibling (seq 1) must not hold up the earlier one (seq 0).
|
||||
siblings = [_sibling(1, "frontend", TaskStatus.IN_PROGRESS)]
|
||||
p1, p2 = _patch_siblings(siblings)
|
||||
with p1, p2:
|
||||
assert await orch._blocked_by_earlier_sibling(task) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_parent_returns_false_without_db() -> None:
|
||||
orch = _new_orchestrator()
|
||||
task = {"id": str(uuid4()), "sequence": 0, "team": "frontend"}
|
||||
# No DB patch: a parentless task must short-circuit before any lookup.
|
||||
assert await orch._blocked_by_earlier_sibling(task) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_failure_falls_through_to_dispatch() -> None:
|
||||
orch = _new_orchestrator()
|
||||
task = {
|
||||
"id": str(uuid4()),
|
||||
"parent_task_id": str(uuid4()),
|
||||
"sequence": 1,
|
||||
"team": "frontend",
|
||||
}
|
||||
boom = patch(
|
||||
"roboco.db.base.get_session_factory", side_effect=RuntimeError("db down")
|
||||
)
|
||||
with boom:
|
||||
# The ordering check must never wedge the dispatcher: degrade to dispatch.
|
||||
assert await orch._blocked_by_earlier_sibling(task) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_skips_blocked_sibling(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""_dispatch_pm_review_work must not spawn a PM for a gated task."""
|
||||
orch = _new_orchestrator()
|
||||
task = {
|
||||
"id": str(uuid4()),
|
||||
"parent_task_id": str(uuid4()),
|
||||
"sequence": 1,
|
||||
"team": "frontend",
|
||||
"assigned_to": str(uuid4()),
|
||||
}
|
||||
spawn = AsyncMock()
|
||||
# monkeypatch.setattr keeps mypy's method-assign check satisfied without
|
||||
# silencing it; the spawn mock is held locally so the assertion is typed.
|
||||
monkeypatch.setattr(orch, "_fetch_tasks", AsyncMock(return_value=[task]))
|
||||
monkeypatch.setattr(
|
||||
orch, "_blocked_by_earlier_sibling", AsyncMock(return_value=True)
|
||||
)
|
||||
monkeypatch.setattr(orch, "spawn_agent", spawn)
|
||||
monkeypatch.setattr(orch, "_resolve_agent_slug", MagicMock(return_value="fe-pm"))
|
||||
monkeypatch.setattr(orch, "_is_agent_active", MagicMock(return_value=False))
|
||||
|
||||
await orch._dispatch_pm_review_work(cast("Any", MagicMock()))
|
||||
|
||||
spawn.assert_not_awaited()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""GitService PR-divergence primitives: rebase_onto_base + close_pull_request.
|
||||
|
||||
These back both the sequence-ordered merge (rebase a later sibling onto the
|
||||
prior one's merged result) and the conflict resolver (rebase a wedged PR,
|
||||
then close-if-superseded / re-merge / escalate). The classification a rebase
|
||||
yields — superseded vs rebased vs conflicts — drives the whole resolution, so
|
||||
each branch is pinned here against a mocked git.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from roboco.services.git import GitService
|
||||
|
||||
|
||||
def _git_service() -> GitService:
|
||||
return GitService.__new__(GitService)
|
||||
|
||||
|
||||
def _result(returncode: int = 0, stdout: str = "") -> Any:
|
||||
return type("R", (), {"returncode": returncode, "stdout": stdout})()
|
||||
|
||||
|
||||
_HEAD = "feature/frontend/root--cell--leaf"
|
||||
_BASE = "feature/frontend/root--cell"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_superseded_when_no_unique_commits() -> None:
|
||||
"""Clean rebase + zero commits ahead of base => superseded (safe to close)."""
|
||||
svc = _git_service()
|
||||
pushed: list[list[str]] = []
|
||||
|
||||
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
|
||||
if args[0] == "push":
|
||||
pushed.append(args)
|
||||
if args[:2] == ["rev-list", "--count"]:
|
||||
return _result(stdout="0\n")
|
||||
return _result()
|
||||
|
||||
with patch.object(svc, "_run_git", new=fake_run):
|
||||
out = await svc.rebase_onto_base(
|
||||
Path("/tmp/ws"), head_branch=_HEAD, base_branch=_BASE, git_token="tok"
|
||||
)
|
||||
assert out == {"status": "superseded"}
|
||||
# A superseded branch must NOT be force-pushed — nothing changed.
|
||||
assert pushed == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_rebased_force_pushes_when_unique_commits() -> None:
|
||||
"""Clean rebase + commits ahead of base => rebased + force-push the head."""
|
||||
svc = _git_service()
|
||||
pushed: list[list[str]] = []
|
||||
|
||||
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
|
||||
if args[0] == "push":
|
||||
pushed.append(args)
|
||||
return _result()
|
||||
if args[:2] == ["rev-list", "--count"]:
|
||||
return _result(stdout="3\n")
|
||||
return _result()
|
||||
|
||||
with patch.object(svc, "_run_git", new=fake_run):
|
||||
out = await svc.rebase_onto_base(
|
||||
Path("/tmp/ws"), head_branch=_HEAD, base_branch=_BASE, git_token="tok"
|
||||
)
|
||||
assert out == {"status": "rebased", "unique_commits": 3}
|
||||
# Only the head branch is force-pushed, with lease, never the base.
|
||||
assert pushed == [["push", "--force-with-lease", "origin", f"HEAD:{_HEAD}"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_conflicts_aborts_and_reports_files() -> None:
|
||||
"""A failed rebase is aborted and the conflicting files reported."""
|
||||
svc = _git_service()
|
||||
aborted = False
|
||||
|
||||
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
|
||||
nonlocal aborted
|
||||
if args == ["rebase", f"origin/{_BASE}"]:
|
||||
return _result(returncode=1)
|
||||
if args[:2] == ["diff", "--name-only"]:
|
||||
return _result(stdout="src/a.tsx\nsrc/b.tsx\n")
|
||||
if args == ["rebase", "--abort"]:
|
||||
aborted = True
|
||||
return _result()
|
||||
return _result()
|
||||
|
||||
with patch.object(svc, "_run_git", new=fake_run):
|
||||
out = await svc.rebase_onto_base(
|
||||
Path("/tmp/ws"), head_branch=_HEAD, base_branch=_BASE, git_token="tok"
|
||||
)
|
||||
assert out == {"status": "conflicts", "files": ["src/a.tsx", "src/b.tsx"]}
|
||||
assert aborted is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_never_force_pushes_on_conflict() -> None:
|
||||
"""Guard: the destructive force-push must not fire when a rebase conflicts."""
|
||||
svc = _git_service()
|
||||
pushed: list[list[str]] = []
|
||||
|
||||
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
|
||||
if args[0] == "push":
|
||||
pushed.append(args)
|
||||
if args == ["rebase", f"origin/{_BASE}"]:
|
||||
return _result(returncode=1)
|
||||
if args[:2] == ["diff", "--name-only"]:
|
||||
return _result(stdout="")
|
||||
return _result()
|
||||
|
||||
with patch.object(svc, "_run_git", new=fake_run):
|
||||
await svc.rebase_onto_base(
|
||||
Path("/tmp/ws"), head_branch=_HEAD, base_branch=_BASE, git_token="tok"
|
||||
)
|
||||
assert pushed == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_pull_request_patches_state_closed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""close_pull_request issues a PATCH state=closed (and an optional comment)."""
|
||||
svc = _git_service()
|
||||
# Stub the task/project/workspace/token/remote resolution chain via
|
||||
# monkeypatch.setattr (not direct assignment) so mypy's method-assign check
|
||||
# stays satisfied without silencing it.
|
||||
task = type("T", (), {"id": "t", "assigned_to": None, "created_by": None})()
|
||||
session = AsyncMock()
|
||||
session.execute = AsyncMock(
|
||||
return_value=type("Res", (), {"scalar_one_or_none": lambda _self: task})()
|
||||
)
|
||||
delete_branch = AsyncMock()
|
||||
monkeypatch.setattr(svc, "session", session, raising=False)
|
||||
monkeypatch.setattr(
|
||||
svc,
|
||||
"_project_for_task",
|
||||
AsyncMock(return_value=type("P", (), {"slug": "proj"})()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
svc, "_resolve_workspace_agent_id", MagicMock(return_value=None)
|
||||
)
|
||||
monkeypatch.setattr(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
monkeypatch.setattr(
|
||||
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
svc, "_parse_github_remote", MagicMock(return_value=("owner", "repo"))
|
||||
)
|
||||
monkeypatch.setattr(svc, "_delete_pr_branch_best_effort", delete_branch)
|
||||
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
class _Resp:
|
||||
is_success = True
|
||||
status_code = 200
|
||||
text = ""
|
||||
|
||||
class _Client:
|
||||
async def __aenter__(self) -> _Client:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_a: Any) -> None:
|
||||
return None
|
||||
|
||||
async def post(self, url: str, **_kw: Any) -> _Resp:
|
||||
calls.append(("POST", url))
|
||||
return _Resp()
|
||||
|
||||
async def patch(self, url: str, **_kw: Any) -> _Resp:
|
||||
calls.append(("PATCH", url))
|
||||
return _Resp()
|
||||
|
||||
with patch("roboco.services.git.httpx.AsyncClient", return_value=_Client()):
|
||||
await svc.close_pull_request(159, comment="superseded by #158")
|
||||
|
||||
assert (
|
||||
"POST",
|
||||
"https://api.github.com/repos/owner/repo/issues/159/comments",
|
||||
) in calls
|
||||
assert ("PATCH", "https://api.github.com/repos/owner/repo/pulls/159") in calls
|
||||
delete_branch.assert_awaited_once()
|
||||
@@ -0,0 +1,91 @@
|
||||
"""get_status must not misreport an unstaged deletion as staged.
|
||||
|
||||
Porcelain encodes the index (staged) state in column 0 and the worktree state
|
||||
in column 1. A worktree-only change has a SPACE in column 0 (e.g. " D file" =
|
||||
unstaged deletion). The old code ran stdout.strip() before splitting, which ate
|
||||
the leading space on the first line, turning " D file" into "D file" — parsed as
|
||||
a STAGED deletion. That false "staged" caused 6 wasted QA cycles when a dev
|
||||
deleted a file but had not staged it. Regression test: the deletion must land in
|
||||
unstaged, never staged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from roboco.services.git import GitService
|
||||
|
||||
|
||||
def _git_service() -> GitService:
|
||||
return GitService.__new__(GitService)
|
||||
|
||||
|
||||
def _result(returncode: int = 0, stdout: str = "") -> Any:
|
||||
return type("R", (), {"returncode": returncode, "stdout": stdout})()
|
||||
|
||||
|
||||
def _fake_run_with_status(porcelain: str) -> Any:
|
||||
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
|
||||
if args[:2] == ["branch", "--show-current"]:
|
||||
return _result(stdout="feature/x\n")
|
||||
if args[:2] == ["status", "--porcelain"]:
|
||||
return _result(stdout=porcelain)
|
||||
return _result(returncode=1) # ahead/behind rev-list -> treated as 0,0
|
||||
|
||||
return fake_run
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unstaged_deletion_first_line_not_reported_as_staged(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
svc = _git_service()
|
||||
monkeypatch.setattr(
|
||||
svc, "_run_git", _fake_run_with_status(" D piragi_patches.py\n")
|
||||
)
|
||||
monkeypatch.setattr(svc, "_ahead_behind", AsyncMock(return_value=(0, 0)))
|
||||
|
||||
_branch, has_changes, staged, unstaged, _untracked, _a, _b = await svc.get_status(
|
||||
Path("/tmp/ws")
|
||||
)
|
||||
|
||||
assert "piragi_patches.py" in unstaged
|
||||
assert "piragi_patches.py" not in staged
|
||||
assert has_changes is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_staged_deletion_still_reported_as_staged(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A genuine staged deletion ('D file') must still read as staged."""
|
||||
svc = _git_service()
|
||||
monkeypatch.setattr(svc, "_run_git", _fake_run_with_status("D gone.py\n"))
|
||||
monkeypatch.setattr(svc, "_ahead_behind", AsyncMock(return_value=(0, 0)))
|
||||
|
||||
_branch, _has, staged, unstaged, _untracked, _a, _b = await svc.get_status(
|
||||
Path("/tmp/ws")
|
||||
)
|
||||
|
||||
assert "gone.py" in staged
|
||||
assert "gone.py" not in unstaged
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_line_unstaged_modify_not_misread(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The strip() bug hit any worktree-only first line, not just deletions."""
|
||||
svc = _git_service()
|
||||
monkeypatch.setattr(svc, "_run_git", _fake_run_with_status(" M app.py\n"))
|
||||
monkeypatch.setattr(svc, "_ahead_behind", AsyncMock(return_value=(0, 0)))
|
||||
|
||||
_branch, _has, staged, unstaged, _untracked, _a, _b = await svc.get_status(
|
||||
Path("/tmp/ws")
|
||||
)
|
||||
|
||||
assert "app.py" in unstaged
|
||||
assert "app.py" not in staged
|
||||
Reference in New Issue
Block a user