Files
roboco/tests/unit/services/test_task_cancel_pr_close.py
T
29f7082030 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>
2026-07-19 17:38:30 +02:00

115 lines
3.3 KiB
Python

"""Cancel closes the task's own open PR (GAP A).
``_delete_task_branch_best_effort`` already force-deletes the task's remote
branch + worktree on cancel, but an open PR (``task.pr_number`` set) was left
open on the forge forever — nothing ever closed it. This is the isolated
unit test for the new ``_close_task_pr_best_effort`` best-effort chokepoint;
``tests/integration/test_task_service_lifecycle_misc.py`` covers the full
``cancel()`` wiring against a real DB.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.services.task import TaskService
def _service() -> TaskService:
svc = TaskService.__new__(TaskService)
svc.log = MagicMock()
svc.session = MagicMock()
return svc
def _session(slug: str | None) -> MagicMock:
session = MagicMock()
result = MagicMock()
result.scalar_one_or_none.return_value = slug
session.execute = AsyncMock(return_value=result)
return session
def _task(*, pr_number: int | None) -> MagicMock:
return MagicMock(id=uuid4(), project_id=uuid4(), pr_number=pr_number)
@pytest.mark.asyncio
async def test_closes_open_pr_via_git_service() -> None:
svc = _service()
task = _task(pr_number=42)
svc.session = _session("roboco-api")
git_service = MagicMock()
git_service.close_task_pr_best_effort = AsyncMock()
with patch(
"roboco.services.git.get_git_service",
MagicMock(return_value=git_service),
):
await svc._close_task_pr_best_effort(task)
git_service.close_task_pr_best_effort.assert_awaited_once_with("roboco-api", 42)
@pytest.mark.asyncio
async def test_skips_when_no_pr_number() -> None:
# Task never opened a PR (or was cancelled before claim) — nothing to
# close, and no project lookup should even fire.
svc = _service()
task = _task(pr_number=None)
svc.session = _session("roboco-api")
git_service = MagicMock()
git_service.close_task_pr_best_effort = AsyncMock()
with patch(
"roboco.services.git.get_git_service",
MagicMock(return_value=git_service),
):
await svc._close_task_pr_best_effort(task)
git_service.close_task_pr_best_effort.assert_not_awaited()
svc.session.execute.assert_not_awaited()
@pytest.mark.asyncio
async def test_skips_when_project_slug_unresolvable() -> None:
svc = _service()
task = _task(pr_number=42)
svc.session = _session(None)
git_service = MagicMock()
git_service.close_task_pr_best_effort = AsyncMock()
with patch(
"roboco.services.git.get_git_service",
MagicMock(return_value=git_service),
):
await svc._close_task_pr_best_effort(task)
git_service.close_task_pr_best_effort.assert_not_awaited()
@pytest.mark.asyncio
async def test_pr_close_failure_does_not_raise() -> None:
# Best-effort: a forge failure logs and never blocks the cancellation.
svc = _service()
task = _task(pr_number=42)
svc.session = _session("roboco-api")
git_service = MagicMock()
git_service.close_task_pr_best_effort = AsyncMock(
side_effect=RuntimeError("forge unreachable")
)
with patch(
"roboco.services.git.get_git_service",
MagicMock(return_value=git_service),
):
await svc._close_task_pr_best_effort(task) # must not raise
svc.log.warning.assert_called_once()