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:
+87
-4
@@ -3652,6 +3652,44 @@ class GitService(BaseService):
|
|||||||
repo_ref, branch_name, git_token
|
repo_ref, branch_name, git_token
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def close_task_pr_best_effort(
|
||||||
|
self, project_slug: str, pr_number: int
|
||||||
|
) -> bool:
|
||||||
|
"""Close a task's still-open PR on cancel/discard. Best-effort.
|
||||||
|
|
||||||
|
Called by `TaskService` on cancellation so a task that never lands
|
||||||
|
doesn't leave its PR open on the forge forever. Mirrors
|
||||||
|
``delete_task_branch``: resolves owner/repo straight off the
|
||||||
|
project's ``git_url`` rather than a workspace checkout, so closing
|
||||||
|
never depends on a live agent clone existing (unlike
|
||||||
|
``close_pull_request``, which needs one to read the remote). A no-op
|
||||||
|
when the token/project lookup fails or the PR is already
|
||||||
|
closed/merged. Returns whether a close request was actually issued.
|
||||||
|
"""
|
||||||
|
git_token = await self._token_for_project(project_slug)
|
||||||
|
if not git_token:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
project_service = get_project_service(self.session)
|
||||||
|
project = await project_service.get_by_slug(project_slug)
|
||||||
|
if not project or not project.git_url:
|
||||||
|
return False
|
||||||
|
repo_ref = self._parse_git_url(project.git_url)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
existing = await self._forge.get_pr(
|
||||||
|
repo_ref, git_token, pr_number, timeout=10.0
|
||||||
|
)
|
||||||
|
if not existing.is_success or existing.json().get("state") != "open":
|
||||||
|
return False
|
||||||
|
resp = await self._forge.update_pr(
|
||||||
|
repo_ref, git_token, pr_number, payload={"state": "closed"}
|
||||||
|
)
|
||||||
|
return bool(resp.is_success)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return False
|
||||||
|
|
||||||
# Per-call cap on the stale-branch sweep so one request can't hang on an
|
# Per-call cap on the stale-branch sweep so one request can't hang on an
|
||||||
# unbounded fan-out of remote-delete calls.
|
# unbounded fan-out of remote-delete calls.
|
||||||
_CLEANUP_BRANCH_LIMIT = 200
|
_CLEANUP_BRANCH_LIMIT = 200
|
||||||
@@ -3663,7 +3701,11 @@ class GitService(BaseService):
|
|||||||
|
|
||||||
Candidates are TERMINAL (completed/cancelled) tasks with a
|
Candidates are TERMINAL (completed/cancelled) tasks with a
|
||||||
``branch_name`` that isn't an environment-ladder rung (a ladder branch
|
``branch_name`` that isn't an environment-ladder rung (a ladder branch
|
||||||
outlives any one task — see ``roboco.models.env_branches``). Capped at
|
outlives any one task — see ``roboco.models.env_branches``) and isn't
|
||||||
|
still load-bearing for a live task — either a NON-terminal task still
|
||||||
|
records this exact branch as its own, or a NON-terminal task is a
|
||||||
|
direct child of the branch's owning task (see
|
||||||
|
``_live_task_dependents``). Capped at
|
||||||
``_CLEANUP_BRANCH_LIMIT`` per call; the window is deterministic
|
``_CLEANUP_BRANCH_LIMIT`` per call; the window is deterministic
|
||||||
(``ORDER BY id``) and cursor-resumable via ``after_task_id`` — task
|
(``ORDER BY id``) and cursor-resumable via ``after_task_id`` — task
|
||||||
rows never change as a side effect of the sweep, so without a cursor a
|
rows never change as a side effect of the sweep, so without a cursor a
|
||||||
@@ -3729,14 +3771,18 @@ class GitService(BaseService):
|
|||||||
branch-cleanup candidates for ``cleanup_stale_branches``.
|
branch-cleanup candidates for ``cleanup_stale_branches``.
|
||||||
|
|
||||||
Returns ``(candidates, truncated, next_cursor)`` — ladder-branch rows
|
Returns ``(candidates, truncated, next_cursor)`` — ladder-branch rows
|
||||||
stay in the window (and so still advance the cursor) but are excluded
|
and still-load-bearing rows (see ``_live_task_dependents``) stay in
|
||||||
from ``candidates``, matching the caller's docstring.
|
the window (and so still advance the cursor) but are excluded from
|
||||||
|
``candidates``, matching the caller's docstring.
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from roboco.db.tables import TaskTable
|
from roboco.db.tables import TaskTable
|
||||||
|
|
||||||
ladder_branches = {rung.branch for rung in effective_environments(project)}
|
ladder_branches = {rung.branch for rung in effective_environments(project)}
|
||||||
|
live_branches, live_parent_ids = await self._live_task_dependents(
|
||||||
|
cast("UUID", project.id)
|
||||||
|
)
|
||||||
query = (
|
query = (
|
||||||
select(TaskTable)
|
select(TaskTable)
|
||||||
.where(TaskTable.project_id == project.id)
|
.where(TaskTable.project_id == project.id)
|
||||||
@@ -3752,9 +3798,46 @@ class GitService(BaseService):
|
|||||||
truncated = len(window) > self._CLEANUP_BRANCH_LIMIT
|
truncated = len(window) > self._CLEANUP_BRANCH_LIMIT
|
||||||
window = window[: self._CLEANUP_BRANCH_LIMIT]
|
window = window[: self._CLEANUP_BRANCH_LIMIT]
|
||||||
next_cursor = str(window[-1].id) if truncated and window else None
|
next_cursor = str(window[-1].id) if truncated and window else None
|
||||||
candidates = [t for t in window if str(t.branch_name) not in ladder_branches]
|
candidates = [
|
||||||
|
t
|
||||||
|
for t in window
|
||||||
|
if str(t.branch_name) not in ladder_branches
|
||||||
|
and str(t.branch_name) not in live_branches
|
||||||
|
and t.id not in live_parent_ids
|
||||||
|
]
|
||||||
return candidates, truncated, next_cursor
|
return candidates, truncated, next_cursor
|
||||||
|
|
||||||
|
async def _live_task_dependents(
|
||||||
|
self, project_id: UUID
|
||||||
|
) -> tuple[set[str], set[UUID]]:
|
||||||
|
"""Non-terminal tasks' own branches + parent ids, scoped to one project.
|
||||||
|
|
||||||
|
Feeds the stale-branch guard: a terminal candidate is still load-
|
||||||
|
bearing when either a NON-terminal task still records this exact
|
||||||
|
branch as its own (a defensive check against a task row reusing a
|
||||||
|
spent branch name), or a NON-terminal task is a direct child of the
|
||||||
|
candidate's owning task — that child's PR base resolves to the
|
||||||
|
parent's own ``branch_name`` (``resolve_parent_branch`` in
|
||||||
|
``gateway/merge_chain.py``), even before the child has opened a PR
|
||||||
|
(so ``_branch_has_open_dependents``, which only sees OPEN PRs, can't
|
||||||
|
catch it). Mirrors the env-ladder rung exclusion in the same window
|
||||||
|
builder — one query, sets checked in Python.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from roboco.db.tables import TaskTable
|
||||||
|
|
||||||
|
terminal = (TaskStatus.COMPLETED, TaskStatus.CANCELLED)
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(TaskTable.branch_name, TaskTable.parent_task_id)
|
||||||
|
.where(TaskTable.project_id == project_id)
|
||||||
|
.where(TaskTable.status.notin_(terminal))
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
live_branches = {str(branch) for branch, _ in rows if branch}
|
||||||
|
live_parent_ids = {parent_id for _, parent_id in rows if parent_id is not None}
|
||||||
|
return live_branches, live_parent_ids
|
||||||
|
|
||||||
async def _cleanup_one_stale_branch(
|
async def _cleanup_one_stale_branch(
|
||||||
self,
|
self,
|
||||||
project_slug: str,
|
project_slug: str,
|
||||||
|
|||||||
@@ -6764,6 +6764,39 @@ class TaskService(BaseService):
|
|||||||
error=str(e),
|
error=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _close_task_pr_best_effort(self, task: TaskTable) -> None:
|
||||||
|
"""Close the task's still-open PR on cancel, if it recorded one.
|
||||||
|
|
||||||
|
Best-effort, never raises — mirrors ``_delete_task_branch_best_effort``.
|
||||||
|
Cancellation already force-deletes the branch/worktree, but left an
|
||||||
|
open PR on the forge forever since nothing ever closed it. Skipped
|
||||||
|
for tasks that never opened one; a no-op if it's already
|
||||||
|
closed/merged (``GitService.close_task_pr_best_effort``).
|
||||||
|
"""
|
||||||
|
pr_number = task.pr_number
|
||||||
|
if not pr_number:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
project_result = await self.session.execute(
|
||||||
|
select(ProjectTable.slug).where(ProjectTable.id == task.project_id)
|
||||||
|
)
|
||||||
|
project_slug = project_result.scalar_one_or_none()
|
||||||
|
if not project_slug:
|
||||||
|
return
|
||||||
|
from roboco.services.git import get_git_service
|
||||||
|
|
||||||
|
git_service = get_git_service(self.session)
|
||||||
|
await git_service.close_task_pr_best_effort(project_slug, int(pr_number))
|
||||||
|
except Exception as e:
|
||||||
|
# Cleanup is best-effort — don't fail the cancel if the
|
||||||
|
# remote is unreachable or the PR is already gone.
|
||||||
|
self.log.warning(
|
||||||
|
"PR close skipped",
|
||||||
|
task_id=str(task.id),
|
||||||
|
pr_number=pr_number,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
async def _remove_task_worktree_best_effort(
|
async def _remove_task_worktree_best_effort(
|
||||||
self, task: TaskTable, project: ProjectTable, *, force_branch_delete: bool
|
self, task: TaskTable, project: ProjectTable, *, force_branch_delete: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -6993,6 +7026,7 @@ class TaskService(BaseService):
|
|||||||
await self._abandon_work_session_for_task(
|
await self._abandon_work_session_for_task(
|
||||||
descendant, reason="parent task cancelled"
|
descendant, reason="parent task cancelled"
|
||||||
)
|
)
|
||||||
|
await self._close_task_pr_best_effort(descendant)
|
||||||
await self._delete_task_branch_best_effort(descendant)
|
await self._delete_task_branch_best_effort(descendant)
|
||||||
|
|
||||||
if cancelled_count > 0:
|
if cancelled_count > 0:
|
||||||
@@ -7006,6 +7040,7 @@ class TaskService(BaseService):
|
|||||||
self._validate_and_set_status(task, TaskStatus.CANCELLED, agent_role)
|
self._validate_and_set_status(task, TaskStatus.CANCELLED, agent_role)
|
||||||
cancelled_now.append(task)
|
cancelled_now.append(task)
|
||||||
await self._abandon_work_session_for_task(task, reason="task cancelled")
|
await self._abandon_work_session_for_task(task, reason="task cancelled")
|
||||||
|
await self._close_task_pr_best_effort(task)
|
||||||
await self._delete_task_branch_best_effort(task)
|
await self._delete_task_branch_best_effort(task)
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_cancelled_task_force_deletes_local_branch(
|
async def test_cancelled_task_force_deletes_local_branch(
|
||||||
cleanup_setup: dict[str, Any],
|
cleanup_setup: dict[str, Any],
|
||||||
|
|||||||
@@ -729,6 +729,7 @@ async def test_cancel_with_branch_and_work_session(
|
|||||||
)
|
)
|
||||||
fake_git = MagicMock()
|
fake_git = MagicMock()
|
||||||
fake_git.delete_task_branch = AsyncMock()
|
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)
|
monkeypatch.setattr("roboco.services.git.get_git_service", lambda _s: fake_git)
|
||||||
out = await svc.cancel(task.id, agent_role="cell_pm")
|
out = await svc.cancel(task.id, agent_role="cell_pm")
|
||||||
assert out is not None
|
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()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_cancel_descendants_cascades_for_authorized_pm(
|
async def test_cancel_descendants_cascades_for_authorized_pm(
|
||||||
task_setup: dict, db_session: AsyncSession
|
task_setup: dict, db_session: AsyncSession
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""GitService.close_task_pr_best_effort (GAP A) — cancellation never closed
|
||||||
|
a task's own PR. Mirrors ``delete_task_branch``: resolves owner/repo
|
||||||
|
straight off the project's ``git_url``, no workspace/clone needed, so it's
|
||||||
|
safe to call from the cancel chokepoint for any task — assigned or not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import roboco.services.git as git_module
|
||||||
|
from roboco.services.git import GitService
|
||||||
|
|
||||||
|
_PR_NUMBER = 42
|
||||||
|
|
||||||
|
|
||||||
|
def _bind(svc: GitService, name: str, value: object) -> None:
|
||||||
|
setattr(svc, name, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _service() -> GitService:
|
||||||
|
svc = GitService.__new__(GitService)
|
||||||
|
_bind(svc, "log", MagicMock())
|
||||||
|
_bind(svc, "session", MagicMock())
|
||||||
|
return svc
|
||||||
|
|
||||||
|
|
||||||
|
def _wire_project(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
*,
|
||||||
|
git_url: str | None = "https://github.com/acme/repo.git",
|
||||||
|
) -> None:
|
||||||
|
project = SimpleNamespace(git_url=git_url) if git_url else None
|
||||||
|
project_svc = MagicMock(get_by_slug=AsyncMock(return_value=project))
|
||||||
|
monkeypatch.setattr(git_module, "get_project_service", lambda _s: project_svc)
|
||||||
|
|
||||||
|
|
||||||
|
def _resp(*, status_code: int = 200, json_payload: dict[str, Any] | None = None) -> Any:
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.status_code = status_code
|
||||||
|
resp.is_success = 200 <= status_code < 300 # noqa: PLR2004
|
||||||
|
resp.json.return_value = json_payload or {}
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_closes_an_open_pr(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_token_for_project", AsyncMock(return_value="tok"))
|
||||||
|
_wire_project(monkeypatch)
|
||||||
|
|
||||||
|
forge = MagicMock(
|
||||||
|
get_pr=AsyncMock(return_value=_resp(json_payload={"state": "open"})),
|
||||||
|
update_pr=AsyncMock(return_value=_resp()),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(GitService, "_forge", property(lambda _self: forge))
|
||||||
|
|
||||||
|
out = await svc.close_task_pr_best_effort("roboco", _PR_NUMBER)
|
||||||
|
|
||||||
|
assert out is True
|
||||||
|
forge.update_pr.assert_awaited_once()
|
||||||
|
call = forge.update_pr.await_args
|
||||||
|
assert call.args[2] == _PR_NUMBER
|
||||||
|
assert call.kwargs["payload"] == {"state": "closed"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_already_closed_pr_is_a_noop(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_token_for_project", AsyncMock(return_value="tok"))
|
||||||
|
_wire_project(monkeypatch)
|
||||||
|
|
||||||
|
forge = MagicMock(
|
||||||
|
get_pr=AsyncMock(return_value=_resp(json_payload={"state": "closed"})),
|
||||||
|
update_pr=AsyncMock(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(GitService, "_forge", property(lambda _self: forge))
|
||||||
|
|
||||||
|
out = await svc.close_task_pr_best_effort("roboco", _PR_NUMBER)
|
||||||
|
|
||||||
|
assert out is False
|
||||||
|
forge.update_pr.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_merged_pr_is_a_noop(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# A merged PR also reports state="closed" on GitHub — never re-close it.
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_token_for_project", AsyncMock(return_value="tok"))
|
||||||
|
_wire_project(monkeypatch)
|
||||||
|
|
||||||
|
forge = MagicMock(
|
||||||
|
get_pr=AsyncMock(
|
||||||
|
return_value=_resp(json_payload={"state": "closed", "merged": True})
|
||||||
|
),
|
||||||
|
update_pr=AsyncMock(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(GitService, "_forge", property(lambda _self: forge))
|
||||||
|
|
||||||
|
out = await svc.close_task_pr_best_effort("roboco", _PR_NUMBER)
|
||||||
|
|
||||||
|
assert out is False
|
||||||
|
forge.update_pr.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_token_is_a_noop(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_token_for_project", AsyncMock(return_value=None))
|
||||||
|
forge = MagicMock(get_pr=AsyncMock(), update_pr=AsyncMock())
|
||||||
|
monkeypatch.setattr(GitService, "_forge", property(lambda _self: forge))
|
||||||
|
|
||||||
|
out = await svc.close_task_pr_best_effort("roboco", _PR_NUMBER)
|
||||||
|
|
||||||
|
assert out is False
|
||||||
|
forge.get_pr.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_project_git_url_is_a_noop(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_token_for_project", AsyncMock(return_value="tok"))
|
||||||
|
_wire_project(monkeypatch, git_url=None)
|
||||||
|
forge = MagicMock(get_pr=AsyncMock(), update_pr=AsyncMock())
|
||||||
|
monkeypatch.setattr(GitService, "_forge", property(lambda _self: forge))
|
||||||
|
|
||||||
|
out = await svc.close_task_pr_best_effort("roboco", _PR_NUMBER)
|
||||||
|
|
||||||
|
assert out is False
|
||||||
|
forge.get_pr.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lookup_transport_error_is_swallowed(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_token_for_project", AsyncMock(return_value="tok"))
|
||||||
|
_wire_project(monkeypatch)
|
||||||
|
|
||||||
|
forge = MagicMock(
|
||||||
|
get_pr=AsyncMock(side_effect=httpx.HTTPError("boom")),
|
||||||
|
update_pr=AsyncMock(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(GitService, "_forge", property(lambda _self: forge))
|
||||||
|
|
||||||
|
out = await svc.close_task_pr_best_effort("roboco", _PR_NUMBER)
|
||||||
|
|
||||||
|
assert out is False
|
||||||
|
forge.update_pr.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_pr_non_success_is_a_noop(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_token_for_project", AsyncMock(return_value="tok"))
|
||||||
|
_wire_project(monkeypatch)
|
||||||
|
|
||||||
|
forge = MagicMock(
|
||||||
|
get_pr=AsyncMock(return_value=_resp(status_code=404)),
|
||||||
|
update_pr=AsyncMock(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(GitService, "_forge", property(lambda _self: forge))
|
||||||
|
|
||||||
|
out = await svc.close_task_pr_best_effort("roboco", _PR_NUMBER)
|
||||||
|
|
||||||
|
assert out is False
|
||||||
|
forge.update_pr.assert_not_awaited()
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""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()
|
||||||
Reference in New Issue
Block a user