mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(git): env-ladder rung protection at the shared remote-delete chokepoint (#651)
Rung protection lived only in delete_task_branch; the post-merge PR- source cleanup (and the stale-branch sweep's shared primitive) could still delete a branch that IS a ladder rung. _protected_branches_for_ deletion(slug) — field ∪ rung names, null-ladder shim included — now feeds _delete_remote_branch_best_effort, so every remote deletion path is covered; delete_task_branch's local rung check is removed as exactly subsumed (verified byte-identical comparison semantics). Bonus closed gap: a renamed trunk (default_branch 'trunk', null ladder) is now delete-protected, which the hardcoded main/master floor never covered. Per adversarial review, the deletion lookup fails CLOSED: a raised project lookup skips the delete with a warning (a skipped best-effort delete just retries next sweep — free safety), while a genuinely-gone project proceeds with the hardcoded floor (its ladder is meaningless). The rebase/sync resolver stays fail-open — a refused rebase on a DB blip would wrongly block work, a different tradeoff, now documented. Panel tooltip updated to the new truth. 29 tests. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -612,7 +612,7 @@ function EditProjectForm({
|
|||||||
|
|
||||||
{/* Protected Branches */}
|
{/* Protected Branches */}
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<HelpTip label="Branches the fleet refuses to rebase onto, sync (force-push) as a task's own branch, or delete on the remote, in addition to the always-protected master/main defaults — matched exactly, case-sensitive. Environment-ladder rungs get separate protection, but only for task-branch cleanup, not a PR's source-branch cleanup after merge.">
|
<HelpTip label="Branches the fleet refuses to rebase onto or sync (force-push) as a task's own branch, in addition to the always-protected master/main defaults — matched exactly, case-sensitive. Every remote branch delete (task-branch cleanup, the stale-branch sweep, and a merged PR's source-branch cleanup) additionally refuses any environment-ladder rung, even one not listed here.">
|
||||||
<Label htmlFor="protected_branch_input">Protected Branches</Label>
|
<Label htmlFor="protected_branch_input">Protected Branches</Label>
|
||||||
</HelpTip>
|
</HelpTip>
|
||||||
{protectedBranches.length > 0 && (
|
{protectedBranches.length > 0 && (
|
||||||
|
|||||||
+110
-27
@@ -1183,21 +1183,29 @@ class GitService(BaseService):
|
|||||||
async def _protected_branches_for(self, project_slug: str | None) -> frozenset[str]:
|
async def _protected_branches_for(self, project_slug: str | None) -> frozenset[str]:
|
||||||
"""The project's own ``protected_branches``, normalized.
|
"""The project's own ``protected_branches``, normalized.
|
||||||
|
|
||||||
Consulted by every hardcoded rebase/delete safety gate as a UNION
|
Consulted by every hardcoded rebase/sync safety gate as a UNION
|
||||||
with its own literal set — this can only ADD branches to what's
|
with its own literal set — this can only ADD branches to what's
|
||||||
refused, never remove one, so a missing/unresolvable project or an
|
refused, never remove one, so a missing/unresolvable project or an
|
||||||
emptied field degrades to exactly the prior hardcoded-only behavior.
|
emptied field degrades to exactly the prior hardcoded-only behavior.
|
||||||
Branch names are matched case-sensitively (git refs are); entries are
|
Branch names are matched case-sensitively (git refs are); entries are
|
||||||
stripped of surrounding whitespace defensively.
|
stripped of surrounding whitespace defensively.
|
||||||
|
|
||||||
|
Fail-OPEN on a lookup error (logged): a rebase/sync refusal wrongly
|
||||||
|
blocking real work over a transient DB blip is the worse tradeoff
|
||||||
|
here — unlike deletion (see :meth:`_protected_branches_for_deletion`),
|
||||||
|
a skipped rebase doesn't get a free retry at the next sweep.
|
||||||
|
|
||||||
|
Deliberately does NOT include environment-ladder rungs — rebase
|
||||||
|
(``rebase``) and force-push sync (``sync_task_branch``) stay scoped to
|
||||||
|
the declared field + the master/main floor; see
|
||||||
|
:meth:`_protected_branches_for_deletion` for the deletion-only
|
||||||
|
superset that adds rungs.
|
||||||
"""
|
"""
|
||||||
if not project_slug:
|
if not project_slug:
|
||||||
return frozenset()
|
return frozenset()
|
||||||
try:
|
try:
|
||||||
project = await get_project_service(self.session).get_by_slug(project_slug)
|
project = await get_project_service(self.session).get_by_slug(project_slug)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Fail-open by design (additive-only protection degrading to the
|
|
||||||
# hardcoded floor is the right posture) — but log it, so a DB
|
|
||||||
# blip silently narrowing protection is at least observable.
|
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"protected_branches lookup failed; degrading to hardcoded floor only",
|
"protected_branches lookup failed; degrading to hardcoded floor only",
|
||||||
project_slug=project_slug,
|
project_slug=project_slug,
|
||||||
@@ -1208,6 +1216,64 @@ class GitService(BaseService):
|
|||||||
return frozenset()
|
return frozenset()
|
||||||
return frozenset(b.strip() for b in project.protected_branches if b.strip())
|
return frozenset(b.strip() for b in project.protected_branches if b.strip())
|
||||||
|
|
||||||
|
async def _protected_branches_for_deletion(
|
||||||
|
self, project_slug: str | None
|
||||||
|
) -> frozenset[str] | None:
|
||||||
|
"""Deletion-only superset of :meth:`_protected_branches_for`: also
|
||||||
|
unions in the project's environment-ladder rung branches (see
|
||||||
|
:mod:`roboco.models.env_branches`).
|
||||||
|
|
||||||
|
Consulted ONLY by ``_delete_remote_branch_best_effort`` — the shared
|
||||||
|
remote-branch-deletion chokepoint every delete path (task-branch
|
||||||
|
cleanup on cancel, the stale-branch sweep, and the merged-PR
|
||||||
|
source-branch cleanup after ``merge_pull_request``/``pr_merge``/
|
||||||
|
``close_pull_request``) routes through — so a ladder rung (e.g. an
|
||||||
|
env-sync PR's own source branch) can never be deleted regardless of
|
||||||
|
which caller triggered it. A null ``environments`` degenerates to a
|
||||||
|
single-rung ladder synthesized from ``default_branch`` (see
|
||||||
|
``effective_environments``), so a renamed trunk is protected here too,
|
||||||
|
not just the hardcoded ``main``/``master`` floor.
|
||||||
|
|
||||||
|
Returns ``None`` — distinct from an empty ``frozenset`` — when the
|
||||||
|
project LOOKUP ITSELF RAISED (a transient DB blip etc.): the caller
|
||||||
|
treats that as "skip this delete entirely" rather than degrading to
|
||||||
|
the hardcoded floor. Deletion fails CLOSED here, unlike
|
||||||
|
``_protected_branches_for``'s fail-OPEN rebase/sync posture, because
|
||||||
|
every sibling failure mode in this chokepoint already fails closed
|
||||||
|
(a missing token or an HTTPError from the forge both skip the
|
||||||
|
delete) and the delete is best-effort anyway — a skipped one just
|
||||||
|
retries at the next sweep, whereas silently proceeding on an
|
||||||
|
unresolvable project could delete a custom-named rung (e.g.
|
||||||
|
"staging") this project actually declares, losing a
|
||||||
|
deployment-lineage branch for good.
|
||||||
|
|
||||||
|
A project that resolves to ``None`` (the row is genuinely gone, not
|
||||||
|
a lookup failure) is NOT the fail-closed case: its ladder is
|
||||||
|
meaningless once the project itself no longer exists, so this
|
||||||
|
returns the empty set — proceed with the hardcoded floor only —
|
||||||
|
rather than refusing forever to clean up an orphaned project's
|
||||||
|
leftover branches.
|
||||||
|
"""
|
||||||
|
if not project_slug:
|
||||||
|
return frozenset()
|
||||||
|
try:
|
||||||
|
project = await get_project_service(self.session).get_by_slug(project_slug)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning(
|
||||||
|
"branch-delete protection lookup failed; skipping delete "
|
||||||
|
"rather than risk silently deleting an unresolvable rung",
|
||||||
|
project_slug=project_slug,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if project is None:
|
||||||
|
return frozenset()
|
||||||
|
fields = frozenset(
|
||||||
|
b.strip() for b in (project.protected_branches or []) if b.strip()
|
||||||
|
)
|
||||||
|
rungs = frozenset(rung.branch for rung in effective_environments(project))
|
||||||
|
return fields | rungs
|
||||||
|
|
||||||
async def _checkout_base_with_fallback(
|
async def _checkout_base_with_fallback(
|
||||||
self,
|
self,
|
||||||
workspace: Path,
|
workspace: Path,
|
||||||
@@ -3653,19 +3719,37 @@ class GitService(BaseService):
|
|||||||
|
|
||||||
Silently swallows errors — cleanup is not critical. Skips branches that
|
Silently swallows errors — cleanup is not critical. Skips branches that
|
||||||
look like project defaults (main / master / develop), any branch in
|
look like project defaults (main / master / develop), any branch in
|
||||||
the project's own declared ``protected_branches`` (when
|
the project's own declared ``protected_branches`` OR one of its
|
||||||
``project_slug`` is given — a UNION with the hardcoded set, so a
|
environment-ladder rungs (when ``project_slug`` is given — see
|
||||||
missing/emptied field only ever loses the extra protection, never the
|
:meth:`_protected_branches_for_deletion`; a UNION with the hardcoded
|
||||||
main/master/develop floor), and any branch that still has open
|
set, so a missing/emptied field or a null ladder only ever loses the
|
||||||
dependent PRs (an active integration target — deleting it would
|
extra protection, never the main/master/develop floor), and any
|
||||||
strand in-flight child work). Returns True if the delete request was
|
branch that still has open dependent PRs (an active integration
|
||||||
issued with no transport error, False on any skip/failure — callers
|
target — deleting it would strand in-flight child work). This is the
|
||||||
that only fire-and-forget can ignore it; the branch-cleanup sweep
|
SHARED chokepoint every remote-delete caller routes through
|
||||||
uses it to report counts.
|
(``delete_task_branch``, the stale-branch sweep, and
|
||||||
|
``_delete_pr_branch_best_effort``'s post-merge PR-source cleanup), so
|
||||||
|
rung protection here covers all of them, not just task-branch
|
||||||
|
cleanup. A project-lookup failure (as opposed to a resolved project
|
||||||
|
or a genuinely-gone one) fails CLOSED — the whole delete is skipped,
|
||||||
|
not just floor-only-protected — since a silent floor-only fallback
|
||||||
|
could delete a custom-named rung the lookup couldn't see. Returns
|
||||||
|
True if the delete request was issued with no transport error, False
|
||||||
|
on any skip/failure — callers that only fire-and-forget can ignore
|
||||||
|
it; the branch-cleanup sweep uses it to report counts.
|
||||||
"""
|
"""
|
||||||
protected = frozenset(
|
project_protected = await self._protected_branches_for_deletion(project_slug)
|
||||||
("main", "master", "develop", "")
|
if project_protected is None:
|
||||||
) | await self._protected_branches_for(project_slug)
|
self.log.warning(
|
||||||
|
"branch delete skipped: protected-branch lookup failed; "
|
||||||
|
"refusing rather than risk deleting an unresolvable rung",
|
||||||
|
branch=branch,
|
||||||
|
owner=repo_ref.owner,
|
||||||
|
repo=repo_ref.repo,
|
||||||
|
project_slug=project_slug,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
protected = frozenset(("main", "master", "develop", "")) | project_protected
|
||||||
if branch in protected:
|
if branch in protected:
|
||||||
return False
|
return False
|
||||||
if await self._branch_has_open_dependents(repo_ref, branch, git_token):
|
if await self._branch_has_open_dependents(repo_ref, branch, git_token):
|
||||||
@@ -3696,7 +3780,9 @@ class GitService(BaseService):
|
|||||||
Silently swallows errors — branch cleanup is not critical.
|
Silently swallows errors — branch cleanup is not critical.
|
||||||
``project_slug``, when given, is forwarded to
|
``project_slug``, when given, is forwarded to
|
||||||
:meth:`_delete_remote_branch_best_effort` so its own protected-branch
|
:meth:`_delete_remote_branch_best_effort` so its own protected-branch
|
||||||
union covers this path too.
|
+ environment-ladder-rung union covers this path too — a PR's own
|
||||||
|
source branch can be a ladder rung (e.g. an env-sync cascade PR), and
|
||||||
|
it is refused just like a task branch would be.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
pr_resp = await self._forge.get_pr(
|
pr_resp = await self._forge.get_pr(
|
||||||
@@ -3720,14 +3806,13 @@ class GitService(BaseService):
|
|||||||
branches don't accumulate on the remote. Returns whether the delete
|
branches don't accumulate on the remote. Returns whether the delete
|
||||||
was actually issued (see ``_delete_remote_branch_best_effort``).
|
was actually issued (see ``_delete_remote_branch_best_effort``).
|
||||||
|
|
||||||
This is the chokepoint every task-scoped remote-delete call routes
|
The environment-ladder guard is NOT re-checked here: it lives in the
|
||||||
through, so the environment-ladder guard lives here rather than only
|
shared ``_delete_remote_branch_best_effort`` chokepoint (via
|
||||||
at each caller: ``_delete_remote_branch_best_effort``'s own
|
``_protected_branches_for_deletion``), which every remote-delete
|
||||||
main/master/develop skip predates the env-ladder model and doesn't
|
caller — this one, the stale-branch sweep, and the merged-PR
|
||||||
know about it (it's a generic branch-delete primitive also used by
|
source-branch cleanup — routes through, so a task's ``branch_name``
|
||||||
the merged-PR source-branch cleanup, which never targets a ladder
|
that coincides with a ladder rung is refused there regardless of
|
||||||
branch by construction) — a task's ``branch_name`` could otherwise
|
which caller asked.
|
||||||
coincide with a ladder rung and get deleted out from under it.
|
|
||||||
"""
|
"""
|
||||||
git_token = await self._token_for_project(project_slug)
|
git_token = await self._token_for_project(project_slug)
|
||||||
if not git_token:
|
if not git_token:
|
||||||
@@ -3740,8 +3825,6 @@ class GitService(BaseService):
|
|||||||
project = await project_service.get_by_slug(project_slug)
|
project = await project_service.get_by_slug(project_slug)
|
||||||
if not project or not project.git_url:
|
if not project or not project.git_url:
|
||||||
return False
|
return False
|
||||||
if branch_name in {r.branch for r in effective_environments(project)}:
|
|
||||||
return False
|
|
||||||
repo_ref = self._parse_git_url(project.git_url)
|
repo_ref = self._parse_git_url(project.git_url)
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -336,13 +336,17 @@ async def test_unknown_project_returns_zeroed_result(
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delete_task_branch_refuses_env_ladder_rung_directly(
|
async def test_delete_task_branch_refuses_env_ladder_rung_directly(
|
||||||
cleanup_setup: dict[str, Any],
|
cleanup_setup: dict[str, Any], monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
"""The chokepoint guard, not just the sweep's candidate filter: even a
|
"""The chokepoint guard, not just the sweep's candidate filter: even a
|
||||||
direct ``delete_task_branch`` call (e.g. the cancel-path caller in
|
direct ``delete_task_branch`` call (e.g. the cancel-path caller in
|
||||||
task.py) must refuse a branch that is an environment-ladder rung — the
|
task.py) must refuse a branch that is an environment-ladder rung. This
|
||||||
generic ``_delete_remote_branch_best_effort`` primitive's own
|
protection now lives entirely inside the shared
|
||||||
main/master/develop skip predates the ladder model and doesn't know it."""
|
``_delete_remote_branch_best_effort`` chokepoint (via
|
||||||
|
``_protected_branches_for_deletion``) rather than as a local check in
|
||||||
|
``delete_task_branch`` — so this test exercises the REAL chokepoint
|
||||||
|
(the fixture's ``AsyncMock`` stub for it is bypassed) and proves the
|
||||||
|
rung short-circuits before any network probe."""
|
||||||
project = cleanup_setup["project"]
|
project = cleanup_setup["project"]
|
||||||
project.environments = [
|
project.environments = [
|
||||||
{"name": "head", "branch": "develop"},
|
{"name": "head", "branch": "develop"},
|
||||||
@@ -350,8 +354,12 @@ async def test_delete_task_branch_refuses_env_ladder_rung_directly(
|
|||||||
]
|
]
|
||||||
await cleanup_setup["db"].flush()
|
await cleanup_setup["db"].flush()
|
||||||
|
|
||||||
ok = await cleanup_setup["svc"].delete_task_branch(project.slug, "develop")
|
svc = GitService(cleanup_setup["db"])
|
||||||
|
monkeypatch.setattr(svc, "_token_for_project", AsyncMock(return_value="tok"))
|
||||||
|
dependents = AsyncMock(return_value=False)
|
||||||
|
monkeypatch.setattr(svc, "_branch_has_open_dependents", dependents)
|
||||||
|
|
||||||
|
ok = await svc.delete_task_branch(project.slug, "develop")
|
||||||
|
|
||||||
assert ok is False
|
assert ok is False
|
||||||
remote_delete = cleanup_setup["svc"]._delete_remote_branch_best_effort
|
dependents.assert_not_awaited()
|
||||||
remote_delete.assert_not_awaited()
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ async def test_delete_skips_default_branch_before_checking_dependents() -> None:
|
|||||||
# hardcoded-only behavior above.
|
# hardcoded-only behavior above.
|
||||||
|
|
||||||
|
|
||||||
def _project_service_returning(project: MagicMock) -> MagicMock:
|
def _project_service_returning(project: MagicMock | None) -> MagicMock:
|
||||||
svc = MagicMock()
|
svc = MagicMock()
|
||||||
svc.get_by_slug = AsyncMock(return_value=project)
|
svc.get_by_slug = AsyncMock(return_value=project)
|
||||||
return svc
|
return svc
|
||||||
@@ -227,6 +227,134 @@ async def test_delete_matches_stripped_branch_case_sensitively() -> None:
|
|||||||
client2.delete.assert_awaited_once()
|
client2.delete.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_refuses_env_ladder_rung_not_in_declared_list() -> None:
|
||||||
|
"""The environment-ladder rung union (2026-07-22 follow-up, #649 gap
|
||||||
|
closure): a branch that ISN'T in the project's declared
|
||||||
|
``protected_branches`` but IS one of its ladder rungs is still refused —
|
||||||
|
``_protected_branches_for_deletion`` unions rungs in on top of the
|
||||||
|
declared field, and this shared chokepoint is where every remote-delete
|
||||||
|
caller (task-branch cleanup, the stale-branch sweep, and the merged-PR
|
||||||
|
source-branch cleanup) ends up."""
|
||||||
|
svc = _service()
|
||||||
|
dep = AsyncMock(return_value=False)
|
||||||
|
_bind(svc, "_branch_has_open_dependents", dep)
|
||||||
|
client = _fake_client()
|
||||||
|
project = MagicMock(
|
||||||
|
protected_branches=["release"],
|
||||||
|
environments=[
|
||||||
|
{"name": "head", "branch": "develop"},
|
||||||
|
{"name": "prod", "branch": "master"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
|
||||||
|
patch(
|
||||||
|
"roboco.services.git.get_project_service",
|
||||||
|
return_value=_project_service_returning(project),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await svc._delete_remote_branch_best_effort(
|
||||||
|
RepoRef("acme", "repo"), "develop", "tok", "acme-repo"
|
||||||
|
)
|
||||||
|
client.delete.assert_not_awaited()
|
||||||
|
dep.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_refuses_null_ladder_default_branch() -> None:
|
||||||
|
"""A project with a renamed trunk and no declared ladder (``environments``
|
||||||
|
null) synthesizes a single-rung ladder from ``default_branch`` — so
|
||||||
|
'trunk' is protected here even though it matches neither the hardcoded
|
||||||
|
main/master/develop floor nor anything in ``protected_branches``."""
|
||||||
|
svc = _service()
|
||||||
|
dep = AsyncMock(return_value=False)
|
||||||
|
_bind(svc, "_branch_has_open_dependents", dep)
|
||||||
|
client = _fake_client()
|
||||||
|
project = MagicMock(
|
||||||
|
protected_branches=[], environments=None, default_branch="trunk"
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
|
||||||
|
patch(
|
||||||
|
"roboco.services.git.get_project_service",
|
||||||
|
return_value=_project_service_returning(project),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await svc._delete_remote_branch_best_effort(
|
||||||
|
RepoRef("acme", "repo"), "trunk", "tok", "acme-repo"
|
||||||
|
)
|
||||||
|
client.delete.assert_not_awaited()
|
||||||
|
dep.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_allows_branch_that_is_neither_field_rung_nor_floor() -> None:
|
||||||
|
"""A project declaring BOTH protected_branches and a real ladder still
|
||||||
|
deletes a branch that is in none of the three protected sets — the
|
||||||
|
rung union doesn't become deny-by-default any more than the field
|
||||||
|
union does."""
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_branch_has_open_dependents", AsyncMock(return_value=False))
|
||||||
|
client = _fake_client()
|
||||||
|
project = MagicMock(
|
||||||
|
protected_branches=["release"],
|
||||||
|
environments=[
|
||||||
|
{"name": "head", "branch": "develop"},
|
||||||
|
{"name": "prod", "branch": "master"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
|
||||||
|
patch(
|
||||||
|
"roboco.services.git.get_project_service",
|
||||||
|
return_value=_project_service_returning(project),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await svc._delete_remote_branch_best_effort(
|
||||||
|
RepoRef("acme", "repo"),
|
||||||
|
"feature/backend/abc--cell--leaf",
|
||||||
|
"tok",
|
||||||
|
"acme-repo",
|
||||||
|
)
|
||||||
|
client.delete.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_pr_branch_refuses_rung_source_via_merge_cleanup_path() -> None:
|
||||||
|
"""The post-merge PR-source-branch cleanup (``_delete_pr_branch_best_effort``
|
||||||
|
← ``merge_pull_request``/``pr_merge``/``close_pull_request``) is the gap
|
||||||
|
#649 left open: it reaches the same shared ``_delete_remote_branch_best_effort``
|
||||||
|
chokepoint, so a PR whose OWN source branch is a ladder rung (e.g. an
|
||||||
|
env-sync cascade PR) is refused too, not just a task-branch delete."""
|
||||||
|
svc = _service()
|
||||||
|
dep = AsyncMock(return_value=False)
|
||||||
|
_bind(svc, "_branch_has_open_dependents", dep)
|
||||||
|
client = _fake_client()
|
||||||
|
pr_resp = MagicMock(is_success=True)
|
||||||
|
pr_resp.json.return_value = {"head": {"ref": "release/env-sync"}}
|
||||||
|
client.get = AsyncMock(return_value=pr_resp)
|
||||||
|
project = MagicMock(
|
||||||
|
protected_branches=[],
|
||||||
|
environments=[
|
||||||
|
{"name": "head", "branch": "develop"},
|
||||||
|
{"name": "prod", "branch": "release/env-sync"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
|
||||||
|
patch(
|
||||||
|
"roboco.services.git.get_project_service",
|
||||||
|
return_value=_project_service_returning(project),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await svc._delete_pr_branch_best_effort(
|
||||||
|
RepoRef("acme", "repo"), 7, "tok", "acme-repo"
|
||||||
|
)
|
||||||
|
client.delete.assert_not_awaited()
|
||||||
|
dep.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delete_union_never_collapses_hardcoded_floor_to_project_list_only() -> (
|
async def test_delete_union_never_collapses_hardcoded_floor_to_project_list_only() -> (
|
||||||
None
|
None
|
||||||
@@ -268,6 +396,71 @@ async def test_delete_union_never_collapses_hardcoded_floor_to_project_list_only
|
|||||||
client2.delete.assert_not_awaited()
|
client2.delete.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
# --- fail-CLOSED on a lookup failure (2026-07-22 adversarial-review follow-up)
|
||||||
|
# `_protected_branches_for_deletion` distinguishes "lookup raised" (skip the
|
||||||
|
# delete entirely — None) from "project genuinely gone" (proceed with the
|
||||||
|
# hardcoded floor — empty frozenset). Every sibling failure mode in this
|
||||||
|
# chokepoint already fails closed (missing token, HTTPError), and a skipped
|
||||||
|
# delete is free — it just retries at the next sweep — so silently degrading
|
||||||
|
# to floor-only on an unresolvable project (which could delete a
|
||||||
|
# custom-named rung like "staging") is the wrong tradeoff here, unlike
|
||||||
|
# _protected_branches_for's fail-open rebase/sync posture.
|
||||||
|
|
||||||
|
|
||||||
|
def _project_service_raising(exc: Exception) -> MagicMock:
|
||||||
|
svc = MagicMock()
|
||||||
|
svc.get_by_slug = AsyncMock(side_effect=exc)
|
||||||
|
return svc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_skips_entirely_when_project_lookup_raises() -> None:
|
||||||
|
"""A transient DB blip during the lookup skips the WHOLE delete (fail
|
||||||
|
CLOSED) rather than degrading to floor-only protection — a custom rung
|
||||||
|
name like "staging" would otherwise slip through undetected."""
|
||||||
|
svc = _service()
|
||||||
|
dep = AsyncMock(return_value=False)
|
||||||
|
_bind(svc, "_branch_has_open_dependents", dep)
|
||||||
|
client = _fake_client()
|
||||||
|
with (
|
||||||
|
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
|
||||||
|
patch(
|
||||||
|
"roboco.services.git.get_project_service",
|
||||||
|
return_value=_project_service_raising(RuntimeError("db blip")),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await svc._delete_remote_branch_best_effort(
|
||||||
|
RepoRef("acme", "repo"), "staging", "tok", "acme-repo"
|
||||||
|
)
|
||||||
|
client.delete.assert_not_awaited()
|
||||||
|
dep.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_proceeds_with_floor_when_project_genuinely_gone() -> None:
|
||||||
|
"""A resolved-to-``None`` project (the row itself no longer exists) is
|
||||||
|
NOT a lookup failure — its ladder is meaningless once the project is
|
||||||
|
gone, so cleaning up its now-orphaned branches proceeds with the
|
||||||
|
hardcoded floor only, rather than being refused forever."""
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_branch_has_open_dependents", AsyncMock(return_value=False))
|
||||||
|
client = _fake_client()
|
||||||
|
with (
|
||||||
|
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
|
||||||
|
patch(
|
||||||
|
"roboco.services.git.get_project_service",
|
||||||
|
return_value=_project_service_returning(None),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await svc._delete_remote_branch_best_effort(
|
||||||
|
RepoRef("acme", "repo"),
|
||||||
|
"feature/backend/abc--cell--leaf",
|
||||||
|
"tok",
|
||||||
|
"acme-repo",
|
||||||
|
)
|
||||||
|
client.delete.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
# --- the open-dependents probe --------------------------------------------
|
# --- the open-dependents probe --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user