mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(git): cancel closes the task's open PR; bulk cleanup spares live dependents (#593)
Task cancellation left the task's PR open on the forge forever: cancel() now best-effort-closes the recorded PR for the task and its cascaded descendants (close_task_pr_best_effort resolves owner/repo off git_url — no clone needed; never raises into the cancel). The bulk stale-branch sweep gains a dependents guard: a branch still recorded by a non-terminal task, or serving as a live child's resolve_parent_branch base, is excluded from the candidate window — mirroring the existing env-ladder-rung skip. Scoped to the sweep, not delete_task_branch, so the BFS cascade-cancel can't falsely block a parent's branch on its own about-to-cancel child. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -165,6 +165,61 @@ async def test_env_ladder_branch_is_excluded(cleanup_setup: dict[str, Any]) -> N
|
||||
assert call.args[1] == "feature/backend/real-task"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_child_branch_dependent_is_excluded(
|
||||
cleanup_setup: dict[str, Any],
|
||||
) -> None:
|
||||
"""GAP B: a completed root's branch is still the merge base a live cell
|
||||
task's PR would target (``resolve_parent_branch`` reads the parent's own
|
||||
``branch_name``) — deleting it out from under an in-progress child that
|
||||
hasn't opened a PR yet (so ``_branch_has_open_dependents`` can't see it)
|
||||
would strand the child. The sweep must skip it."""
|
||||
root = _task(
|
||||
cleanup_setup, branch="feature/main_pm/root", status=TaskStatus.COMPLETED
|
||||
)
|
||||
await cleanup_setup["db"].flush()
|
||||
child = _task(
|
||||
cleanup_setup,
|
||||
branch="feature/backend/root--cell",
|
||||
status=TaskStatus.IN_PROGRESS,
|
||||
)
|
||||
child.parent_task_id = root.id
|
||||
_task(
|
||||
cleanup_setup, branch="feature/backend/unrelated", status=TaskStatus.COMPLETED
|
||||
)
|
||||
await cleanup_setup["db"].flush()
|
||||
|
||||
result = await cleanup_setup["svc"].cleanup_stale_branches(
|
||||
cleanup_setup["project"].slug
|
||||
)
|
||||
|
||||
# Only the unrelated completed task's branch is a candidate — the root's
|
||||
# branch is still load-bearing for its live child.
|
||||
assert result == (1, 1, 0, 0, False, None)
|
||||
call = cleanup_setup["ws_svc"].delete_local_branch.await_args
|
||||
assert call is not None
|
||||
assert call.args[1] == "feature/backend/unrelated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_branch_still_claimed_by_a_live_task_is_excluded(
|
||||
cleanup_setup: dict[str, Any],
|
||||
) -> None:
|
||||
"""Defensive case: a NON-terminal task still recording this exact branch
|
||||
as its own must never be swept out from under it, even though the
|
||||
candidate is a *different*, terminal task row."""
|
||||
_task(cleanup_setup, branch="feature/backend/reused", status=TaskStatus.COMPLETED)
|
||||
_task(cleanup_setup, branch="feature/backend/reused", status=TaskStatus.IN_PROGRESS)
|
||||
await cleanup_setup["db"].flush()
|
||||
|
||||
result = await cleanup_setup["svc"].cleanup_stale_branches(
|
||||
cleanup_setup["project"].slug
|
||||
)
|
||||
|
||||
assert result == (0, 0, 0, 0, False, None)
|
||||
cleanup_setup["ws_svc"].delete_local_branch.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_task_force_deletes_local_branch(
|
||||
cleanup_setup: dict[str, Any],
|
||||
|
||||
@@ -729,6 +729,7 @@ async def test_cancel_with_branch_and_work_session(
|
||||
)
|
||||
fake_git = MagicMock()
|
||||
fake_git.delete_task_branch = AsyncMock()
|
||||
fake_git.close_task_pr_best_effort = AsyncMock()
|
||||
monkeypatch.setattr("roboco.services.git.get_git_service", lambda _s: fake_git)
|
||||
out = await svc.cancel(task.id, agent_role="cell_pm")
|
||||
assert out is not None
|
||||
@@ -736,6 +737,57 @@ async def test_cancel_with_branch_and_work_session(
|
||||
fake_git.delete_task_branch.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_closes_open_pr(
|
||||
task_setup: dict,
|
||||
db_session: AsyncSession,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""GAP A: cancelling a task with an open PR closes it on the forge —
|
||||
previously ``cancel`` force-deleted the branch/worktree but left
|
||||
``pr_number`` PRs open forever."""
|
||||
svc = task_setup["svc"]
|
||||
task = await svc.create(_req(task_setup))
|
||||
task.branch_name = "feature/backend/x"
|
||||
task.pr_number = 42
|
||||
task.pr_url = "https://example.com/r/pull/42"
|
||||
await db_session.flush()
|
||||
|
||||
fake_git = MagicMock()
|
||||
fake_git.delete_task_branch = AsyncMock()
|
||||
fake_git.close_task_pr_best_effort = AsyncMock()
|
||||
monkeypatch.setattr("roboco.services.git.get_git_service", lambda _s: fake_git)
|
||||
|
||||
out = await svc.cancel(task.id, agent_role="cell_pm")
|
||||
|
||||
assert out is not None
|
||||
fake_git.close_task_pr_best_effort.assert_awaited_once_with(
|
||||
task_setup["project_slug"], 42
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_skips_pr_close_without_pr_number(
|
||||
task_setup: dict,
|
||||
db_session: AsyncSession,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
svc = task_setup["svc"]
|
||||
task = await svc.create(_req(task_setup))
|
||||
task.branch_name = "feature/backend/x"
|
||||
await db_session.flush()
|
||||
|
||||
fake_git = MagicMock()
|
||||
fake_git.delete_task_branch = AsyncMock()
|
||||
fake_git.close_task_pr_best_effort = AsyncMock()
|
||||
monkeypatch.setattr("roboco.services.git.get_git_service", lambda _s: fake_git)
|
||||
|
||||
out = await svc.cancel(task.id, agent_role="cell_pm")
|
||||
|
||||
assert out is not None
|
||||
fake_git.close_task_pr_best_effort.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_descendants_cascades_for_authorized_pm(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
|
||||
Reference in New Issue
Block a user