mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(git): protected-branches enforcement + panel editor (#649)
projects.protected_branches existed end-to-end but nothing consulted it — the panel had no editor and the git safety checks used hardcoded sets. Now: GitService._protected_branches_for(slug) (frozenset, stripped, fail-open to the hardcoded floor with a warning log) is unioned — never replacing, only tightening — into rebase()'s refusal set, the shared _delete_remote_branch_best_effort skip set (threaded through every caller: task cleanup, PR merge/close cleanup), and sync_task_branch, which now refuses to force-push a protected-named head (the dev-facing sync_branch verb path the HTTP-only fix would have missed). Matching is exact and case-sensitive; an empty list degrades to exactly the old hardcoded behavior, pinned by union-floor regression tests (master/main stay refused regardless of the project list). Panel: chips editor for the field in the edit-project dialog (add via Enter/comma, paste-splitting on comma-separated lists, dedup, clear-to- empty persists []) with an honest tooltip scoped to what is actually enforced. Tests cover both the incumbent GitHub-App dialog suite and the new Protected Branches suite in one harness. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -28,6 +28,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.base import ValidationError
|
||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||||
|
||||
|
||||
@@ -336,6 +337,37 @@ async def test_sync_branch_git_failure_steers_to_i_am_blocked() -> None:
|
||||
assert "i_am_blocked" in (env.remediate or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_branch_protected_head_refusal_steers_to_i_am_blocked() -> None:
|
||||
"""GitService.sync_task_branch's protected-HEAD-branch guard (2026-07-22
|
||||
follow-up — a mis-set branch_name matching master/main or a project's
|
||||
declared protected_branches) surfaces through the same generic
|
||||
invalid_state/i_am_blocked path as any other git failure — the
|
||||
choreographer doesn't need to special-case it, only propagate it."""
|
||||
aid = uuid4()
|
||||
tid = uuid4()
|
||||
t = _task(tid=tid, aid=aid)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
||||
git_svc = AsyncMock()
|
||||
git_svc.sync_task_branch.side_effect = ValidationError(
|
||||
f"REBASE_FORBIDDEN: task branch_name '{_BRANCH}' is a protected branch"
|
||||
)
|
||||
deps = _make_deps(task=task_svc, git=git_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
with patch(
|
||||
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
||||
new=AsyncMock(return_value=_BASE),
|
||||
):
|
||||
env = await c.sync_branch(aid, tid)
|
||||
|
||||
assert env.error == "invalid_state"
|
||||
assert "REBASE_FORBIDDEN" in (env.message or "")
|
||||
assert "i_am_blocked" in (env.remediate or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_branch_passes_stash_flag_through() -> None:
|
||||
"""stash=True on the verb forwards to GitService.sync_task_branch."""
|
||||
|
||||
@@ -80,6 +80,194 @@ async def test_delete_skips_default_branch_before_checking_dependents() -> None:
|
||||
dep.assert_not_awaited()
|
||||
|
||||
|
||||
# --- projects.protected_branches UNION (2026-07-22 follow-up) -------------
|
||||
# `_delete_remote_branch_best_effort` unions its hardcoded skip tuple with
|
||||
# the project's own declared `protected_branches` when a project_slug is
|
||||
# given. The union can only ADD protection: a missing project_slug, an
|
||||
# unresolvable project, or an emptied field must reproduce the exact
|
||||
# hardcoded-only behavior above.
|
||||
|
||||
|
||||
def _project_service_returning(project: MagicMock) -> MagicMock:
|
||||
svc = MagicMock()
|
||||
svc.get_by_slug = AsyncMock(return_value=project)
|
||||
return svc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_skips_project_declared_protected_branch() -> None:
|
||||
"""A custom protected branch (not in the hardcoded set) is refused when
|
||||
the project declares it — the open-dependents probe is never reached,
|
||||
mirroring the hardcoded-name short-circuit above."""
|
||||
svc = _service()
|
||||
dep = AsyncMock(return_value=False)
|
||||
_bind(svc, "_branch_has_open_dependents", dep)
|
||||
client = _fake_client()
|
||||
project = MagicMock(protected_branches=["release"])
|
||||
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"), "release", "tok", "acme-repo"
|
||||
)
|
||||
client.delete.assert_not_awaited()
|
||||
dep.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_allows_branch_not_in_projects_protected_list() -> None:
|
||||
"""A branch that isn't hardcoded AND isn't in the project's declared
|
||||
list is deleted normally — the union only blocks what's actually
|
||||
listed, it doesn't become deny-by-default."""
|
||||
svc = _service()
|
||||
_bind(svc, "_branch_has_open_dependents", AsyncMock(return_value=False))
|
||||
client = _fake_client()
|
||||
project = MagicMock(protected_branches=["release"])
|
||||
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_empty_protected_branches_matches_hardcoded_only_behavior() -> (
|
||||
None
|
||||
):
|
||||
"""An empty (or null) protected_branches field degrades to exactly the
|
||||
prior hardcoded-only behavior — clearing the list never loosens
|
||||
anything, but it also never invents new protection."""
|
||||
svc = _service()
|
||||
_bind(svc, "_branch_has_open_dependents", AsyncMock(return_value=False))
|
||||
client = _fake_client()
|
||||
project = MagicMock(protected_branches=[])
|
||||
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_no_project_slug_matches_hardcoded_only_behavior() -> None:
|
||||
"""Omitting project_slug entirely (legacy call shape) never touches the
|
||||
project service and behaves byte-for-byte like before this change."""
|
||||
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") as get_project_service,
|
||||
):
|
||||
await svc._delete_remote_branch_best_effort(
|
||||
RepoRef("acme", "repo"), "feature/backend/abc--cell--leaf", "tok"
|
||||
)
|
||||
client.delete.assert_awaited_once()
|
||||
get_project_service.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_matches_stripped_branch_case_sensitively() -> None:
|
||||
"""Stored entries are stripped of whitespace defensively, but matching
|
||||
stays case-sensitive (git branch names are case-sensitive): a
|
||||
differently-cased request is NOT protected by a stored ' Release '."""
|
||||
svc = _service()
|
||||
dep = AsyncMock(return_value=False)
|
||||
_bind(svc, "_branch_has_open_dependents", dep)
|
||||
client = _fake_client()
|
||||
project = MagicMock(protected_branches=[" Release "])
|
||||
with (
|
||||
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
|
||||
patch(
|
||||
"roboco.services.git.get_project_service",
|
||||
return_value=_project_service_returning(project),
|
||||
),
|
||||
):
|
||||
# Exact match after stripping -> refused.
|
||||
await svc._delete_remote_branch_best_effort(
|
||||
RepoRef("acme", "repo"), "Release", "tok", "acme-repo"
|
||||
)
|
||||
client.delete.assert_not_awaited()
|
||||
dep.assert_not_awaited()
|
||||
|
||||
client2 = _fake_client()
|
||||
with (
|
||||
patch("roboco.services.git.httpx.AsyncClient", return_value=client2),
|
||||
patch(
|
||||
"roboco.services.git.get_project_service",
|
||||
return_value=_project_service_returning(project),
|
||||
),
|
||||
):
|
||||
# Different case -> not the same git ref -> allowed.
|
||||
await svc._delete_remote_branch_best_effort(
|
||||
RepoRef("acme", "repo"), "release", "tok", "acme-repo"
|
||||
)
|
||||
client2.delete.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_union_never_collapses_hardcoded_floor_to_project_list_only() -> (
|
||||
None
|
||||
):
|
||||
"""A project declaring its OWN protected_branches (e.g. ["release"]) must
|
||||
NOT replace the hardcoded main/master/develop skip — the union is
|
||||
additive, never a substitution. master and main stay refused regardless
|
||||
of what the project's list contains."""
|
||||
project = MagicMock(protected_branches=["release"])
|
||||
|
||||
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_returning(project),
|
||||
),
|
||||
):
|
||||
await svc._delete_remote_branch_best_effort(
|
||||
RepoRef("acme", "repo"), "master", "tok", "acme-repo"
|
||||
)
|
||||
client.delete.assert_not_awaited()
|
||||
dep.assert_not_awaited()
|
||||
|
||||
client2 = _fake_client()
|
||||
with (
|
||||
patch("roboco.services.git.httpx.AsyncClient", return_value=client2),
|
||||
patch(
|
||||
"roboco.services.git.get_project_service",
|
||||
return_value=_project_service_returning(project),
|
||||
),
|
||||
):
|
||||
await svc._delete_remote_branch_best_effort(
|
||||
RepoRef("acme", "repo"), "main", "tok", "acme-repo"
|
||||
)
|
||||
client2.delete.assert_not_awaited()
|
||||
|
||||
|
||||
# --- the open-dependents probe --------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ def _git_service() -> GitService:
|
||||
"""Instantiate GitService without a real DB session."""
|
||||
svc = GitService.__new__(GitService)
|
||||
svc.log = MagicMock() # silence warning/info calls
|
||||
# A placeholder — only touched (as an opaque arg to a patched
|
||||
# get_project_service) by the protected_branches union tests below.
|
||||
svc.session = MagicMock()
|
||||
return svc
|
||||
|
||||
|
||||
@@ -303,6 +306,249 @@ async def test_rebase_raises_validation_error_when_head_branch_is_main(
|
||||
await svc.rebase(_WORKSPACE, "feature/backend/some-task")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rebase() — projects.protected_branches UNION (2026-07-22 follow-up)
|
||||
# ---------------------------------------------------------------------------
|
||||
# rebase()'s hardcoded {"master", "main"} refusal is unioned with the
|
||||
# project's own declared protected_branches when project_slug is given. The
|
||||
# union can only ADD refusals: no project_slug, an unresolvable project, or
|
||||
# an emptied field must reproduce the exact hardcoded-only behavior above.
|
||||
|
||||
|
||||
def _project_service_returning(project: MagicMock) -> MagicMock:
|
||||
svc = MagicMock()
|
||||
svc.get_by_slug = AsyncMock(return_value=project)
|
||||
return svc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_raises_for_project_declared_protected_target_branch() -> None:
|
||||
"""A custom protected branch (not master/main) is refused as a rebase
|
||||
target when the project declares it, before any git command runs."""
|
||||
svc = _git_service()
|
||||
project = MagicMock(protected_branches=["release"])
|
||||
with (
|
||||
patch(
|
||||
"roboco.services.git.get_project_service",
|
||||
return_value=_project_service_returning(project),
|
||||
),
|
||||
pytest.raises(ValidationError, match="REBASE_FORBIDDEN"),
|
||||
):
|
||||
await svc.rebase(_WORKSPACE, "release", "acme-repo")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_allows_target_not_in_projects_protected_list(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A target that isn't master/main AND isn't in the project's declared
|
||||
list rebases normally — the union doesn't become deny-by-default."""
|
||||
run = AsyncMock(return_value=_result())
|
||||
monkeypatch.setattr(GitService, "_run_git", run)
|
||||
monkeypatch.setattr(
|
||||
GitService, "get_current_branch", AsyncMock(return_value="feature/x")
|
||||
)
|
||||
svc = _git_service()
|
||||
project = MagicMock(protected_branches=["release"])
|
||||
with patch(
|
||||
"roboco.services.git.get_project_service",
|
||||
return_value=_project_service_returning(project),
|
||||
):
|
||||
conflict, files = await svc.rebase(
|
||||
_WORKSPACE, "feature/backend/some-task", "acme-repo"
|
||||
)
|
||||
assert (conflict, files) == (False, [])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_empty_protected_branches_matches_hardcoded_only_behavior(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An empty protected_branches field degrades to exactly the prior
|
||||
master/main-only behavior — no extra refusal is invented."""
|
||||
run = AsyncMock(return_value=_result())
|
||||
monkeypatch.setattr(GitService, "_run_git", run)
|
||||
monkeypatch.setattr(
|
||||
GitService, "get_current_branch", AsyncMock(return_value="feature/x")
|
||||
)
|
||||
svc = _git_service()
|
||||
project = MagicMock(protected_branches=[])
|
||||
with patch(
|
||||
"roboco.services.git.get_project_service",
|
||||
return_value=_project_service_returning(project),
|
||||
):
|
||||
conflict, files = await svc.rebase(
|
||||
_WORKSPACE, "feature/backend/some-task", "acme-repo"
|
||||
)
|
||||
assert (conflict, files) == (False, [])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_no_project_slug_matches_hardcoded_only_behavior(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Omitting project_slug entirely (legacy call shape) never touches the
|
||||
project service and behaves byte-for-byte like before this change."""
|
||||
run = AsyncMock(return_value=_result())
|
||||
monkeypatch.setattr(GitService, "_run_git", run)
|
||||
monkeypatch.setattr(
|
||||
GitService, "get_current_branch", AsyncMock(return_value="feature/x")
|
||||
)
|
||||
svc = _git_service()
|
||||
with patch("roboco.services.git.get_project_service") as get_project_service:
|
||||
conflict, files = await svc.rebase(_WORKSPACE, "feature/backend/some-task")
|
||||
assert (conflict, files) == (False, [])
|
||||
get_project_service.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_matches_stripped_target_case_sensitively() -> None:
|
||||
"""Stored entries are stripped defensively, but matching stays
|
||||
case-sensitive: a differently-cased target is NOT refused by a stored
|
||||
' Release '."""
|
||||
project = MagicMock(protected_branches=[" Release "])
|
||||
|
||||
svc = _git_service()
|
||||
with (
|
||||
patch(
|
||||
"roboco.services.git.get_project_service",
|
||||
return_value=_project_service_returning(project),
|
||||
),
|
||||
pytest.raises(ValidationError, match="REBASE_FORBIDDEN"),
|
||||
):
|
||||
await svc.rebase(_WORKSPACE, "Release", "acme-repo")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebase_union_never_collapses_hardcoded_floor_to_project_list_only() -> (
|
||||
None
|
||||
):
|
||||
"""A project declaring its OWN protected_branches (e.g. ["release"]) must
|
||||
NOT replace the hardcoded master/main refusal — the union is additive,
|
||||
never a substitution. Both master and main stay refused regardless of
|
||||
what the project's list contains."""
|
||||
project = MagicMock(protected_branches=["release"])
|
||||
|
||||
svc = _git_service()
|
||||
with (
|
||||
patch(
|
||||
"roboco.services.git.get_project_service",
|
||||
return_value=_project_service_returning(project),
|
||||
),
|
||||
pytest.raises(ValidationError, match="REBASE_FORBIDDEN"),
|
||||
):
|
||||
await svc.rebase(_WORKSPACE, "master", "acme-repo")
|
||||
|
||||
svc2 = _git_service()
|
||||
with (
|
||||
patch(
|
||||
"roboco.services.git.get_project_service",
|
||||
return_value=_project_service_returning(project),
|
||||
),
|
||||
pytest.raises(ValidationError, match="REBASE_FORBIDDEN"),
|
||||
):
|
||||
await svc2.rebase(_WORKSPACE, "main", "acme-repo")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sync_task_branch() — protected HEAD branch guard (2026-07-22 follow-up)
|
||||
# ---------------------------------------------------------------------------
|
||||
# sync_branch force-pushes (with lease) the task's OWN branch_name, not the
|
||||
# base — so the guard that matters here is on the HEAD, not the base (the
|
||||
# choreographer's _sync_base_refused already guards the base and is
|
||||
# untouched). A branch_name that IS master/main or one of the project's
|
||||
# declared protected_branches means branch_name was mis-set; refuse before
|
||||
# any workspace/rebase work.
|
||||
|
||||
|
||||
def _sync_task(branch_name: str) -> MagicMock:
|
||||
# assigned_to=None so _resolve_workspace_agent_id (no actor_agent_id
|
||||
# passed) falls through to its None default instead of trying to parse
|
||||
# an auto-generated MagicMock attribute as a UUID.
|
||||
return MagicMock(id=uuid4(), branch_name=branch_name, assigned_to=None)
|
||||
|
||||
|
||||
def _patch_sync_plumbing(
|
||||
monkeypatch: pytest.MonkeyPatch, project: MagicMock
|
||||
) -> AsyncMock:
|
||||
"""Stub every collaborator sync_task_branch calls AFTER the HEAD guard,
|
||||
so a test that reaches them proves the guard let a normal branch through
|
||||
without actually touching a filesystem or running git. Returns the
|
||||
rebase_onto_base mock so callers can assert on it."""
|
||||
monkeypatch.setattr(
|
||||
GitService, "_project_for_task", AsyncMock(return_value=project)
|
||||
)
|
||||
# _protected_branches_for (called from the new HEAD guard) resolves the
|
||||
# project independently via get_project_service, not _project_for_task.
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.git.get_project_service",
|
||||
lambda _session: _project_service_returning(project),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
GitService, "get_workspace", AsyncMock(return_value=Path("/tmp/clone"))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
GitService, "_get_project_token_or_raise", AsyncMock(return_value="tok")
|
||||
)
|
||||
monkeypatch.setattr(GitService, "_ensure_worktree_for_commit", AsyncMock())
|
||||
rebase_onto_base = AsyncMock(
|
||||
return_value={"status": "rebased", "unique_commits": 1}
|
||||
)
|
||||
monkeypatch.setattr(GitService, "rebase_onto_base", rebase_onto_base)
|
||||
return rebase_onto_base
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_task_branch_refuses_project_declared_protected_head(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A task whose branch_name IS a project-declared protected branch is
|
||||
refused before any workspace/rebase work runs."""
|
||||
project = MagicMock(slug="acme-repo", protected_branches=["release"])
|
||||
rebase_onto_base = _patch_sync_plumbing(monkeypatch, project)
|
||||
svc = _git_service()
|
||||
task = _sync_task("release")
|
||||
|
||||
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
|
||||
await svc.sync_task_branch(task, base_branch="feature/backend/parent")
|
||||
|
||||
rebase_onto_base.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_task_branch_allows_normal_head_unaffected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A normal task branch_name (not in the project's protected list) syncs
|
||||
through exactly as before — the guard doesn't become deny-by-default."""
|
||||
project = MagicMock(slug="acme-repo", protected_branches=["release"])
|
||||
rebase_onto_base = _patch_sync_plumbing(monkeypatch, project)
|
||||
svc = _git_service()
|
||||
task = _sync_task("feature/backend/abc12345")
|
||||
|
||||
result = await svc.sync_task_branch(task, base_branch="feature/backend/parent")
|
||||
|
||||
assert result == {"status": "rebased", "unique_commits": 1}
|
||||
rebase_onto_base.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_task_branch_empty_protected_branches_matches_current_behavior(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An empty (or null) protected_branches field degrades to exactly the
|
||||
prior master/main-only behavior — a normal branch still syncs fine."""
|
||||
project = MagicMock(slug="acme-repo", protected_branches=[])
|
||||
rebase_onto_base = _patch_sync_plumbing(monkeypatch, project)
|
||||
svc = _git_service()
|
||||
task = _sync_task("feature/backend/abc12345")
|
||||
|
||||
result = await svc.sync_task_branch(task, base_branch="feature/backend/parent")
|
||||
|
||||
assert result == {"status": "rebased", "unique_commits": 1}
|
||||
rebase_onto_base.assert_awaited_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pull() safety-gate tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user