Some checks failed
Smoke Test / smoke (pull_request) Failing after 19s
Architecture Lint / Linter Tests (pull_request) Successful in 22s
Validate Config / YAML Lint (pull_request) Failing after 15s
Validate Config / JSON Validate (pull_request) Successful in 18s
Validate Config / Python Syntax & Import Check (pull_request) Failing after 57s
Validate Config / Python Test Suite (pull_request) Has been skipped
Validate Config / Shell Script Lint (pull_request) Failing after 1m0s
Validate Config / Cron Syntax Check (pull_request) Successful in 11s
Validate Config / Deploy Script Dry Run (pull_request) Successful in 11s
Validate Config / Playbook Schema Validation (pull_request) Successful in 22s
Architecture Lint / Lint Repository (pull_request) Failing after 22s
PR Checklist / pr-checklist (pull_request) Successful in 4m20s
Add set_branch_protection() and related methods to GiteaClient. Fixes #482 via three-way enforcement: 1. gitea_client.py: Added get/set/delete_branch_protection() API wrappers 2. bin/enable-branch-protection.py: Idempotent script protects main branches of all Timmy_Foundation core repos with: ── required_approvals: 1 (at least one human review) ── require_status_checks: true (CI must pass) ── restrict_merge: true (only admins + reviewers can merge) 3. tests/test_gitea_client_core.py: Added TestBranchProtection suite Usage: after merging, run: bin/enable-branch-protection.py --dry-run # verify bin/enable-branch-protection.py # apply to all core repos This prevents agents from merging their own PRs before human review.
452 lines
17 KiB
Python
452 lines
17 KiB
Python
"""Tests for gitea_client.py — the typed, sovereign API client.
|
|
|
|
gitea_client.py is 539 lines with zero tests in this repo (there are
|
|
tests in hermes-agent, but not here where it's actually used).
|
|
|
|
These tests cover:
|
|
- All 6 dataclass from_dict() constructors (User, Label, Issue, etc.)
|
|
- Defensive handling of missing/null fields from Gitea API
|
|
- find_unassigned_issues() filtering logic
|
|
- find_agent_issues() case-insensitive matching
|
|
- GiteaError formatting
|
|
- _repo_path() formatting
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# Import gitea_client directly via importlib to avoid any sys.modules mocking
|
|
# from test_tasks_core which stubs gitea_client as a MagicMock.
|
|
REPO_ROOT = Path(__file__).parent.parent
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"gitea_client_real",
|
|
REPO_ROOT / "gitea_client.py",
|
|
)
|
|
_gc = importlib.util.module_from_spec(_spec)
|
|
sys.modules["gitea_client_real"] = _gc
|
|
_spec.loader.exec_module(_gc)
|
|
|
|
User = _gc.User
|
|
Label = _gc.Label
|
|
Issue = _gc.Issue
|
|
Comment = _gc.Comment
|
|
PullRequest = _gc.PullRequest
|
|
PRFile = _gc.PRFile
|
|
GiteaError = _gc.GiteaError
|
|
GiteaClient = _gc.GiteaClient
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# DATACLASS DESERIALIZATION
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
class TestUserFromDict:
|
|
def test_full_user(self):
|
|
u = User.from_dict({"id": 1, "login": "timmy", "full_name": "Timmy", "email": "t@t.com"})
|
|
assert u.id == 1
|
|
assert u.login == "timmy"
|
|
assert u.full_name == "Timmy"
|
|
assert u.email == "t@t.com"
|
|
|
|
def test_minimal_user(self):
|
|
"""Missing fields default to empty."""
|
|
u = User.from_dict({})
|
|
assert u.id == 0
|
|
assert u.login == ""
|
|
|
|
def test_extra_fields_ignored(self):
|
|
"""Unknown fields from Gitea are silently ignored."""
|
|
u = User.from_dict({"id": 1, "login": "x", "avatar_url": "http://..."})
|
|
assert u.login == "x"
|
|
|
|
|
|
class TestLabelFromDict:
|
|
def test_label(self):
|
|
lb = Label.from_dict({"id": 5, "name": "bug", "color": "#ff0000"})
|
|
assert lb.id == 5
|
|
assert lb.name == "bug"
|
|
assert lb.color == "#ff0000"
|
|
|
|
|
|
class TestIssueFromDict:
|
|
def test_full_issue(self):
|
|
issue = Issue.from_dict({
|
|
"number": 42,
|
|
"title": "Fix the bug",
|
|
"body": "Please fix it",
|
|
"state": "open",
|
|
"user": {"id": 1, "login": "reporter"},
|
|
"assignees": [{"id": 2, "login": "dev"}],
|
|
"labels": [{"id": 3, "name": "bug"}],
|
|
"comments": 5,
|
|
})
|
|
assert issue.number == 42
|
|
assert issue.user.login == "reporter"
|
|
assert len(issue.assignees) == 1
|
|
assert issue.assignees[0].login == "dev"
|
|
assert len(issue.labels) == 1
|
|
assert issue.comments == 5
|
|
|
|
def test_null_assignees_handled(self):
|
|
"""Gitea returns null for assignees sometimes — the exact bug
|
|
that crashed find_unassigned_issues() before the defensive fix."""
|
|
issue = Issue.from_dict({
|
|
"number": 1,
|
|
"title": "test",
|
|
"body": None,
|
|
"state": "open",
|
|
"user": {"id": 1, "login": "x"},
|
|
"assignees": None,
|
|
})
|
|
assert issue.assignees == []
|
|
assert issue.body == ""
|
|
|
|
def test_null_labels_handled(self):
|
|
"""Labels can also be null."""
|
|
issue = Issue.from_dict({
|
|
"number": 1,
|
|
"title": "test",
|
|
"state": "open",
|
|
"user": {},
|
|
"labels": None,
|
|
})
|
|
assert issue.labels == []
|
|
|
|
def test_missing_user_defaults(self):
|
|
"""Issue with no user field doesn't crash."""
|
|
issue = Issue.from_dict({"number": 1, "title": "t", "state": "open"})
|
|
assert issue.user.login == ""
|
|
|
|
|
|
class TestCommentFromDict:
|
|
def test_comment(self):
|
|
c = Comment.from_dict({
|
|
"id": 10,
|
|
"body": "LGTM",
|
|
"user": {"id": 1, "login": "reviewer"},
|
|
})
|
|
assert c.id == 10
|
|
assert c.body == "LGTM"
|
|
assert c.user.login == "reviewer"
|
|
|
|
def test_null_body(self):
|
|
c = Comment.from_dict({"id": 1, "body": None, "user": {}})
|
|
assert c.body == ""
|
|
|
|
|
|
class TestPullRequestFromDict:
|
|
def test_full_pr(self):
|
|
pr = PullRequest.from_dict({
|
|
"number": 99,
|
|
"title": "Add feature",
|
|
"body": "Description here",
|
|
"state": "open",
|
|
"user": {"id": 1, "login": "dev"},
|
|
"head": {"ref": "feature-branch"},
|
|
"base": {"ref": "main"},
|
|
"mergeable": True,
|
|
"merged": False,
|
|
"changed_files": 3,
|
|
})
|
|
assert pr.number == 99
|
|
assert pr.head_branch == "feature-branch"
|
|
assert pr.base_branch == "main"
|
|
assert pr.mergeable is True
|
|
|
|
def test_null_head_base(self):
|
|
"""Handles null head/base objects."""
|
|
pr = PullRequest.from_dict({
|
|
"number": 1, "title": "t", "state": "open",
|
|
"user": {}, "head": None, "base": None,
|
|
})
|
|
assert pr.head_branch == ""
|
|
assert pr.base_branch == ""
|
|
|
|
def test_null_merged(self):
|
|
"""merged can be null from Gitea."""
|
|
pr = PullRequest.from_dict({
|
|
"number": 1, "title": "t", "state": "open",
|
|
"user": {}, "merged": None,
|
|
})
|
|
assert pr.merged is False
|
|
|
|
|
|
class TestPRFileFromDict:
|
|
def test_pr_file(self):
|
|
f = PRFile.from_dict({
|
|
"filename": "src/main.py",
|
|
"status": "modified",
|
|
"additions": 10,
|
|
"deletions": 3,
|
|
})
|
|
assert f.filename == "src/main.py"
|
|
assert f.status == "modified"
|
|
assert f.additions == 10
|
|
assert f.deletions == 3
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# ERROR HANDLING
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
class TestGiteaError:
|
|
def test_error_formatting(self):
|
|
err = GiteaError(404, "not found", "http://example.com/api/v1/repos/x")
|
|
assert "404" in str(err)
|
|
assert "not found" in str(err)
|
|
|
|
def test_error_attributes(self):
|
|
err = GiteaError(500, "internal")
|
|
assert err.status == 500
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# CLIENT HELPER METHODS
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
class TestClientHelpers:
|
|
def test_repo_path(self):
|
|
"""_repo_path converts owner/name to API path."""
|
|
client = GiteaClient.__new__(GiteaClient)
|
|
assert client._repo_path("Timmy_Foundation/the-nexus") == "/repos/Timmy_Foundation/the-nexus"
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# FILTERING LOGIC — find_unassigned_issues, find_agent_issues
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
class TestFindUnassigned:
|
|
"""Tests for find_unassigned_issues() filtering logic.
|
|
|
|
These tests use pre-constructed Issue objects to test the filtering
|
|
without making any API calls.
|
|
"""
|
|
|
|
def _make_issue(self, number, assignees=None, labels=None, title="test"):
|
|
return Issue(
|
|
number=number, title=title, body="", state="open",
|
|
user=User(id=0, login=""),
|
|
assignees=[User(id=0, login=a) for a in (assignees or [])],
|
|
labels=[Label(id=0, name=lb) for lb in (labels or [])],
|
|
)
|
|
|
|
def test_filters_assigned_issues(self):
|
|
"""Issues with assignees are excluded."""
|
|
from unittest.mock import patch
|
|
|
|
issues = [
|
|
self._make_issue(1, assignees=["dev"]),
|
|
self._make_issue(2), # unassigned
|
|
]
|
|
|
|
client = GiteaClient.__new__(GiteaClient)
|
|
with patch.object(client, "list_issues", return_value=issues):
|
|
result = client.find_unassigned_issues("repo")
|
|
|
|
assert len(result) == 1
|
|
assert result[0].number == 2
|
|
|
|
def test_excludes_by_label(self):
|
|
"""Issues with excluded labels are filtered."""
|
|
from unittest.mock import patch
|
|
|
|
issues = [
|
|
self._make_issue(1, labels=["wontfix"]),
|
|
self._make_issue(2, labels=["bug"]),
|
|
]
|
|
|
|
client = GiteaClient.__new__(GiteaClient)
|
|
with patch.object(client, "list_issues", return_value=issues):
|
|
result = client.find_unassigned_issues("repo", exclude_labels=["wontfix"])
|
|
|
|
assert len(result) == 1
|
|
assert result[0].number == 2
|
|
|
|
def test_excludes_by_title_pattern(self):
|
|
"""Issues matching title patterns are filtered."""
|
|
from unittest.mock import patch
|
|
|
|
issues = [
|
|
self._make_issue(1, title="[PHASE] Research AI"),
|
|
self._make_issue(2, title="Fix login bug"),
|
|
]
|
|
|
|
client = GiteaClient.__new__(GiteaClient)
|
|
with patch.object(client, "list_issues", return_value=issues):
|
|
result = client.find_unassigned_issues(
|
|
"repo", exclude_title_patterns=["[PHASE]"]
|
|
)
|
|
|
|
assert len(result) == 1
|
|
assert result[0].number == 2
|
|
|
|
|
|
class TestFindAgentIssues:
|
|
"""Tests for find_agent_issues() case-insensitive matching."""
|
|
|
|
def test_case_insensitive_match(self):
|
|
from unittest.mock import patch
|
|
|
|
issues = [
|
|
Issue(number=1, title="t", body="", state="open",
|
|
user=User(0, ""), assignees=[User(0, "Timmy")], labels=[]),
|
|
]
|
|
|
|
client = GiteaClient.__new__(GiteaClient)
|
|
with patch.object(client, "list_issues", return_value=issues):
|
|
result = client.find_agent_issues("repo", "timmy")
|
|
|
|
assert len(result) == 1
|
|
|
|
def test_no_match_for_different_agent(self):
|
|
from unittest.mock import patch
|
|
|
|
issues = [
|
|
Issue(number=1, title="t", body="", state="open",
|
|
user=User(0, ""), assignees=[User(0, "Timmy")], labels=[]),
|
|
]
|
|
|
|
client = GiteaClient.__new__(GiteaClient)
|
|
with patch.object(client, "list_issues", return_value=issues):
|
|
result = client.find_agent_issues("repo", "claude")
|
|
|
|
assert len(result) == 0
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# BRANCH PROTECTION — Issue #482
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
class TestBranchProtection:
|
|
"""Tests for branch protection enforcement (Closes #482)."""
|
|
|
|
def _mock_protection(
|
|
self,
|
|
monkeypatch,
|
|
client,
|
|
response: dict | None,
|
|
status: int = 200,
|
|
):
|
|
"""Helper to mock _get/_post/_delete for branch protection endpoints."""
|
|
def fake_request(
|
|
method: str,
|
|
path: str,
|
|
data: dict | None = None,
|
|
params: dict | None = None,
|
|
retries: int = 3,
|
|
backoff: float = 1.5,
|
|
):
|
|
if method == "GET" and "/branch_protection/" in path:
|
|
if status == 404:
|
|
from gitea_client import GiteaError
|
|
raise GiteaError(404, "not found", path)
|
|
return response if response is not None else {}
|
|
if method == "POST" and "/branch_protection/" in path:
|
|
return {"status": "ok", "protected": True, **data}
|
|
if method == "DELETE" and "/branch_protection/" in path:
|
|
from gitea_client import GiteaError
|
|
if status == 404:
|
|
raise GiteaError(404, "not found", path)
|
|
return {"status": "ok"}
|
|
# Fall back to original for other endpoints
|
|
raise AssertionError(f"Unexpected call: {method} {path}")
|
|
|
|
monkeypatch.setattr(client, "_request", fake_request)
|
|
|
|
def test_get_branch_protection_returns_none_when_no_protection(
|
|
self, monkeypatch
|
|
):
|
|
"""404 → None means branch is unprotected."""
|
|
client = GiteaClient.__new__(GiteaClient)
|
|
self._mock_protection(monkeypatch, client, None, status=404)
|
|
result = client.get_branch_protection("owner/repo", "main")
|
|
assert result is None
|
|
|
|
def test_get_branch_protection_returns_settings(self, monkeypatch):
|
|
"""200 with JSON dict returns those settings."""
|
|
client = GiteaClient.__new__(GiteaClient)
|
|
protection = {
|
|
"required_approvals": 2,
|
|
"require_status_checks": True,
|
|
"restrict_merge": False,
|
|
}
|
|
self._mock_protection(monkeypatch, client, protection)
|
|
result = client.get_branch_protection("owner/repo", "main")
|
|
assert result == protection
|
|
|
|
def test_set_branch_protection_posts_correct_payload(self, monkeypatch):
|
|
"""POST sends all protection fields to Gitea."""
|
|
client = GiteaClient.__new__(GiteaClient)
|
|
posted: dict[str, object] = {}
|
|
|
|
def capture_post(method, path, data=None, **kw):
|
|
if method == "POST":
|
|
posted.update(data or {})
|
|
return {"status": "ok", "protected": True}
|
|
|
|
monkeypatch.setattr(client, "_request", capture_post)
|
|
result = client.set_branch_protection(
|
|
"Timmy_Foundation/timmy-config",
|
|
"main",
|
|
required_approvals=1,
|
|
require_status_checks=True,
|
|
restrict_merge=True,
|
|
)
|
|
|
|
assert posted["required_approvals"] == 1
|
|
assert posted["require_status_checks"] is True
|
|
assert posted["restrict_merge"] is True
|
|
# Defaults should be present
|
|
assert posted["allow_force_pushes"] is False
|
|
assert posted["allow_deletions"] is False
|
|
|
|
def test_delete_branch_protection(self, monkeypatch):
|
|
"""DELETE removes protection; 404 is already-gone."""
|
|
client = GiteaClient.__new__(GiteaClient)
|
|
calls = []
|
|
|
|
def fake_delete(method, path, **kw):
|
|
if method == "DELETE" and "/branch_protection/" in path:
|
|
calls.append(path)
|
|
return {"status": "ok"}
|
|
raise AssertionError(path)
|
|
|
|
monkeypatch.setattr(client, "_request", fake_delete)
|
|
ok = client.delete_branch_protection("owner/repo", "main")
|
|
assert ok is True
|
|
assert any("branch_protection/main" in c for c in calls)
|
|
|
|
def test_delete_branch_protection_idempotent(self, monkeypatch):
|
|
"""404 on delete is treated as already-gone and returns False."""
|
|
from gitea_client import GiteaError
|
|
|
|
client = GiteaClient.__new__(GiteaClient)
|
|
|
|
def raise_404(*args, **kw):
|
|
raise GiteaError(404, "not found", "")
|
|
|
|
monkeypatch.setattr(client, "_request", raise_404)
|
|
ok = client.delete_branch_protection("owner/repo", "main")
|
|
assert ok is False
|
|
|
|
def test_set_branch_protection_url_encodes_branch(self, monkeypatch):
|
|
"""Branch names with special chars are URL-encoded."""
|
|
client = GiteaClient.__new__(GiteaClient)
|
|
captured: dict[str, object] = {}
|
|
|
|
def capture_post(method, path, data=None, **kw):
|
|
if method == "POST":
|
|
captured["path"] = path
|
|
captured["data"] = data
|
|
return {"protected": True}
|
|
|
|
monkeypatch.setattr(client, "_request", capture_post)
|
|
client.set_branch_protection("o/r", "feature/foo-bar")
|
|
|
|
assert "/branch_protection/feature%2Ffoo-bar" in captured["path"]
|