[sovereign] The Orchestration Client Timmy Deserves
WHAT THIS IS
============
The Gitea client is the API foundation that every orchestration
module depends on — graph_store.py, knowledge_ingester.py, the
playbook engine, and tasks.py in timmy-home.
Until now it was 60 lines and 3 methods (get_file, create_file,
update_file). This made every orchestration module hand-roll its
own urllib calls with no retry, no pagination, and no error
handling.
WHAT CHANGED
============
Expanded from 60 → 519 lines. Still zero dependencies (pure stdlib).
File operations: get_file, create_file, update_file (unchanged API)
Issues: list, get, create, comment, find_unassigned
Pull Requests: list, get, create, review, get_diff
Branches: create, delete
Labels: list, add_to_issue
Notifications: list, mark_read
Repository: get_repo, list_org_repos
RELIABILITY
===========
- Retry with random jitter on 429/5xx (same pattern as SessionDB)
- Automatic pagination across multi-page results
- Defensive None handling on assignees/labels (audit bug fix)
- GiteaError exception with status_code/url attributes
- Token loading from ~/.timmy/gemini_gitea_token or env vars
WHAT IT FIXES
=============
- tasks.py crashed with TypeError when iterating None assignees
on issues created without setting one (Gitea returns null).
find_unassigned_issues() now uses 'or []' on the assignees
field, matching the same defensive pattern used in SessionDB.
- No module provided issue commenting, PR reviewing, branch
management, or label operations — the playbook engine could
describe these operations but not execute them.
BACKWARD COMPATIBILITY
======================
The three original methods (get_file, create_file, update_file)
maintain identical signatures. graph_store.py and
knowledge_ingester.py import and call them without changes.
TESTS
=====
27 new tests — all pass:
- Core HTTP (5): auth, params, body encoding, None filtering
- Retry (5): 429, 502, 503, non-retryable 404, max exhaustion
- Pagination (3): single page, multi-page, max_items
- Issues (4): list, comment, None assignees, label exclusion
- Pull requests (2): create, review
- Backward compat (4): signatures, constructor env fallback
- Token config (2): missing file, valid file
- Error handling (2): attributes, exception hierarchy
Signed-off-by: gemini <gemini@hermes.local>
This commit is contained in:
375
tests/tools/test_gitea_client.py
Normal file
375
tests/tools/test_gitea_client.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""Tests for the sovereign Gitea API client.
|
||||
|
||||
Validates:
|
||||
- Retry logic with jitter on transient errors (429, 502, 503)
|
||||
- Pagination across multi-page results
|
||||
- Defensive None handling (assignees, labels)
|
||||
- Error handling and GiteaError
|
||||
- find_unassigned_issues filtering
|
||||
- Token loading from config file
|
||||
- Backward compatibility (existing get_file/create_file/update_file API)
|
||||
|
||||
These tests are fully self-contained — no network calls, no Gitea server,
|
||||
no firecrawl dependency. The gitea_client module is imported directly by
|
||||
file path to bypass tools/__init__.py's eager imports.
|
||||
"""
|
||||
|
||||
import io
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ── Direct module import ─────────────────────────────────────────────
|
||||
# Import gitea_client directly by file path to bypass tools/__init__.py
|
||||
# which eagerly imports web_tools → firecrawl (not always installed).
|
||||
|
||||
import importlib.util
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"gitea_client_test",
|
||||
PROJECT_ROOT / "tools" / "gitea_client.py",
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
GiteaClient = _mod.GiteaClient
|
||||
GiteaError = _mod.GiteaError
|
||||
_load_token_config = _mod._load_token_config
|
||||
|
||||
# Module path for patching — must target our loaded module, not tools.gitea_client
|
||||
_MOD_NAME = "gitea_client_test"
|
||||
sys.modules[_MOD_NAME] = _mod
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _make_response(data: Any, status: int = 200):
|
||||
"""Create a mock HTTP response context manager."""
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps(data).encode()
|
||||
resp.status = status
|
||||
resp.__enter__ = MagicMock(return_value=resp)
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
|
||||
|
||||
def _make_http_error(code: int, msg: str):
|
||||
"""Create a real urllib HTTPError for testing."""
|
||||
return urllib.error.HTTPError(
|
||||
url="http://test",
|
||||
code=code,
|
||||
msg=msg,
|
||||
hdrs={}, # type: ignore
|
||||
fp=io.BytesIO(msg.encode()),
|
||||
)
|
||||
|
||||
|
||||
# ── Fixtures ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Client with no real credentials (won't hit network)."""
|
||||
return GiteaClient(base_url="http://localhost:3000", token="test_token")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_urlopen():
|
||||
"""Patch urllib.request.urlopen on our directly-loaded module."""
|
||||
with patch.object(_mod.urllib.request, "urlopen") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
# ── Core request tests ───────────────────────────────────────────────
|
||||
|
||||
class TestCoreRequest:
|
||||
def test_successful_get(self, client, mock_urlopen):
|
||||
"""Basic GET request returns parsed JSON."""
|
||||
mock_urlopen.return_value = _make_response({"id": 1, "name": "test"})
|
||||
result = client._request("GET", "/repos/org/repo")
|
||||
assert result == {"id": 1, "name": "test"}
|
||||
mock_urlopen.assert_called_once()
|
||||
|
||||
def test_auth_header_set(self, client, mock_urlopen):
|
||||
"""Token is included in Authorization header."""
|
||||
mock_urlopen.return_value = _make_response({})
|
||||
client._request("GET", "/test")
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
assert req.get_header("Authorization") == "token test_token"
|
||||
|
||||
def test_post_sends_json_body(self, client, mock_urlopen):
|
||||
"""POST with data sends JSON-encoded body."""
|
||||
mock_urlopen.return_value = _make_response({"id": 42})
|
||||
client._request("POST", "/test", data={"title": "hello"})
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
assert req.data == json.dumps({"title": "hello"}).encode()
|
||||
assert req.get_method() == "POST"
|
||||
|
||||
def test_params_become_query_string(self, client, mock_urlopen):
|
||||
"""Query params are URL-encoded."""
|
||||
mock_urlopen.return_value = _make_response([])
|
||||
client._request("GET", "/issues", params={"state": "open", "limit": 50})
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
assert "state=open" in req.full_url
|
||||
assert "limit=50" in req.full_url
|
||||
|
||||
def test_none_params_excluded(self, client, mock_urlopen):
|
||||
"""None values in params dict are excluded from query string."""
|
||||
mock_urlopen.return_value = _make_response([])
|
||||
client._request("GET", "/issues", params={"state": "open", "labels": None})
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
assert "state=open" in req.full_url
|
||||
assert "labels" not in req.full_url
|
||||
|
||||
|
||||
# ── Retry tests ──────────────────────────────────────────────────────
|
||||
|
||||
class TestRetry:
|
||||
def test_retries_on_429(self, client, mock_urlopen):
|
||||
"""429 (rate limit) triggers retry with jitter."""
|
||||
mock_urlopen.side_effect = [
|
||||
_make_http_error(429, "rate limited"),
|
||||
_make_response({"ok": True}),
|
||||
]
|
||||
with patch.object(_mod.time, "sleep"):
|
||||
result = client._request("GET", "/test")
|
||||
assert result == {"ok": True}
|
||||
assert mock_urlopen.call_count == 2
|
||||
|
||||
def test_retries_on_502(self, client, mock_urlopen):
|
||||
"""502 (bad gateway) triggers retry."""
|
||||
mock_urlopen.side_effect = [
|
||||
_make_http_error(502, "bad gateway"),
|
||||
_make_response({"recovered": True}),
|
||||
]
|
||||
with patch.object(_mod.time, "sleep"):
|
||||
result = client._request("GET", "/test")
|
||||
assert result == {"recovered": True}
|
||||
|
||||
def test_retries_on_503(self, client, mock_urlopen):
|
||||
"""503 (service unavailable) triggers retry."""
|
||||
mock_urlopen.side_effect = [
|
||||
_make_http_error(503, "unavailable"),
|
||||
_make_http_error(503, "unavailable"),
|
||||
_make_response({"third_time": True}),
|
||||
]
|
||||
with patch.object(_mod.time, "sleep"):
|
||||
result = client._request("GET", "/test")
|
||||
assert result == {"third_time": True}
|
||||
assert mock_urlopen.call_count == 3
|
||||
|
||||
def test_non_retryable_error_raises_immediately(self, client, mock_urlopen):
|
||||
"""404 is not retryable — raises GiteaError immediately."""
|
||||
mock_urlopen.side_effect = _make_http_error(404, "not found")
|
||||
with pytest.raises(GiteaError) as exc_info:
|
||||
client._request("GET", "/nonexistent")
|
||||
assert exc_info.value.status_code == 404
|
||||
assert mock_urlopen.call_count == 1
|
||||
|
||||
def test_max_retries_exhausted(self, client, mock_urlopen):
|
||||
"""After max retries, raises the last error."""
|
||||
mock_urlopen.side_effect = [
|
||||
_make_http_error(503, "unavailable"),
|
||||
] * 4
|
||||
with patch.object(_mod.time, "sleep"):
|
||||
with pytest.raises(GiteaError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status_code == 503
|
||||
|
||||
|
||||
# ── Pagination tests ─────────────────────────────────────────────────
|
||||
|
||||
class TestPagination:
|
||||
def test_single_page(self, client, mock_urlopen):
|
||||
"""Single page of results (fewer items than limit)."""
|
||||
items = [{"id": i} for i in range(10)]
|
||||
mock_urlopen.return_value = _make_response(items)
|
||||
result = client._paginate("/repos/org/repo/issues")
|
||||
assert len(result) == 10
|
||||
assert mock_urlopen.call_count == 1
|
||||
|
||||
def test_multi_page(self, client, mock_urlopen):
|
||||
"""Results spanning multiple pages."""
|
||||
page1 = [{"id": i} for i in range(50)]
|
||||
page2 = [{"id": i} for i in range(50, 75)]
|
||||
mock_urlopen.side_effect = [
|
||||
_make_response(page1),
|
||||
_make_response(page2),
|
||||
]
|
||||
result = client._paginate("/test")
|
||||
assert len(result) == 75
|
||||
assert mock_urlopen.call_count == 2
|
||||
|
||||
def test_max_items_respected(self, client, mock_urlopen):
|
||||
"""max_items truncates results."""
|
||||
page1 = [{"id": i} for i in range(50)]
|
||||
mock_urlopen.return_value = _make_response(page1)
|
||||
result = client._paginate("/test", max_items=20)
|
||||
assert len(result) == 20
|
||||
|
||||
|
||||
# ── Issue methods ────────────────────────────────────────────────────
|
||||
|
||||
class TestIssues:
|
||||
def test_list_issues(self, client, mock_urlopen):
|
||||
"""list_issues passes correct params."""
|
||||
mock_urlopen.return_value = _make_response([
|
||||
{"number": 1, "title": "Bug"},
|
||||
{"number": 2, "title": "Feature"},
|
||||
])
|
||||
result = client.list_issues("org/repo", state="open")
|
||||
assert len(result) == 2
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
assert "state=open" in req.full_url
|
||||
assert "type=issues" in req.full_url
|
||||
|
||||
def test_create_issue_comment(self, client, mock_urlopen):
|
||||
"""create_issue_comment sends body."""
|
||||
mock_urlopen.return_value = _make_response({"id": 99, "body": "Fixed"})
|
||||
result = client.create_issue_comment("org/repo", 42, "Fixed in PR #102")
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
body = json.loads(req.data)
|
||||
assert body["body"] == "Fixed in PR #102"
|
||||
assert "/repos/org/repo/issues/42/comments" in req.full_url
|
||||
|
||||
def test_find_unassigned_none_assignees(self, client, mock_urlopen):
|
||||
"""find_unassigned_issues handles None assignees field.
|
||||
|
||||
Gitea sometimes returns null for assignees on issues created
|
||||
without setting one. This was a bug found in the audit —
|
||||
tasks.py crashed with TypeError when iterating None.
|
||||
"""
|
||||
mock_urlopen.return_value = _make_response([
|
||||
{"number": 1, "title": "Bug", "assignees": None, "labels": []},
|
||||
{"number": 2, "title": "Assigned", "assignees": [{"login": "dev"}], "labels": []},
|
||||
{"number": 3, "title": "Empty", "assignees": [], "labels": []},
|
||||
])
|
||||
result = client.find_unassigned_issues("org/repo")
|
||||
assert len(result) == 2
|
||||
assert result[0]["number"] == 1
|
||||
assert result[1]["number"] == 3
|
||||
|
||||
def test_find_unassigned_excludes_labels(self, client, mock_urlopen):
|
||||
"""find_unassigned_issues respects exclude_labels."""
|
||||
mock_urlopen.return_value = _make_response([
|
||||
{"number": 1, "title": "Bug", "assignees": None,
|
||||
"labels": [{"name": "wontfix"}]},
|
||||
{"number": 2, "title": "Todo", "assignees": None,
|
||||
"labels": [{"name": "enhancement"}]},
|
||||
])
|
||||
result = client.find_unassigned_issues(
|
||||
"org/repo", exclude_labels=["wontfix"]
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0]["number"] == 2
|
||||
|
||||
|
||||
# ── Pull Request methods ────────────────────────────────────────────
|
||||
|
||||
class TestPullRequests:
|
||||
def test_create_pull(self, client, mock_urlopen):
|
||||
"""create_pull sends correct data."""
|
||||
mock_urlopen.return_value = _make_response(
|
||||
{"number": 105, "state": "open"}
|
||||
)
|
||||
result = client.create_pull(
|
||||
"org/repo", title="Fix bugs",
|
||||
head="fix-branch", base="main", body="Fixes #42",
|
||||
)
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
body = json.loads(req.data)
|
||||
assert body["title"] == "Fix bugs"
|
||||
assert body["head"] == "fix-branch"
|
||||
assert body["base"] == "main"
|
||||
assert result["number"] == 105
|
||||
|
||||
def test_create_pull_review(self, client, mock_urlopen):
|
||||
"""create_pull_review sends review event."""
|
||||
mock_urlopen.return_value = _make_response({"id": 1})
|
||||
client.create_pull_review("org/repo", 42, "LGTM", event="APPROVE")
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
body = json.loads(req.data)
|
||||
assert body["event"] == "APPROVE"
|
||||
assert body["body"] == "LGTM"
|
||||
|
||||
|
||||
# ── Backward compatibility ──────────────────────────────────────────
|
||||
|
||||
class TestBackwardCompat:
|
||||
"""Ensure the expanded client doesn't break graph_store.py or
|
||||
knowledge_ingester.py which import the old 3-method interface."""
|
||||
|
||||
def test_get_file_signature(self, client):
|
||||
"""get_file accepts (repo, path, ref) — same as before."""
|
||||
sig = inspect.signature(client.get_file)
|
||||
params = list(sig.parameters.keys())
|
||||
assert params == ["repo", "path", "ref"]
|
||||
|
||||
def test_create_file_signature(self, client):
|
||||
"""create_file accepts (repo, path, content, message, branch)."""
|
||||
sig = inspect.signature(client.create_file)
|
||||
params = list(sig.parameters.keys())
|
||||
assert "repo" in params and "content" in params and "message" in params
|
||||
|
||||
def test_update_file_signature(self, client):
|
||||
"""update_file accepts (repo, path, content, message, sha, branch)."""
|
||||
sig = inspect.signature(client.update_file)
|
||||
params = list(sig.parameters.keys())
|
||||
assert "sha" in params
|
||||
|
||||
def test_constructor_env_var_fallback(self):
|
||||
"""Constructor reads GITEA_URL and GITEA_TOKEN from env."""
|
||||
with patch.dict(os.environ, {
|
||||
"GITEA_URL": "http://myserver:3000",
|
||||
"GITEA_TOKEN": "mytoken",
|
||||
}):
|
||||
c = GiteaClient()
|
||||
assert c.base_url == "http://myserver:3000"
|
||||
assert c.token == "mytoken"
|
||||
|
||||
|
||||
# ── Token config loading ─────────────────────────────────────────────
|
||||
|
||||
class TestTokenConfig:
|
||||
def test_load_missing_file(self, tmp_path):
|
||||
"""Missing token file returns empty dict."""
|
||||
with patch.object(_mod.Path, "home", return_value=tmp_path / "nope"):
|
||||
config = _load_token_config()
|
||||
assert config == {"url": "", "token": ""}
|
||||
|
||||
def test_load_valid_file(self, tmp_path):
|
||||
"""Valid token file is parsed correctly."""
|
||||
token_file = tmp_path / ".timmy" / "gemini_gitea_token"
|
||||
token_file.parent.mkdir(parents=True)
|
||||
token_file.write_text(
|
||||
'GITEA_URL=http://143.198.27.163:3000\n'
|
||||
'GITEA_TOKEN=abc123\n'
|
||||
)
|
||||
with patch.object(_mod.Path, "home", return_value=tmp_path):
|
||||
config = _load_token_config()
|
||||
assert config["url"] == "http://143.198.27.163:3000"
|
||||
assert config["token"] == "abc123"
|
||||
|
||||
|
||||
# ── GiteaError ───────────────────────────────────────────────────────
|
||||
|
||||
class TestGiteaError:
|
||||
def test_error_attributes(self):
|
||||
err = GiteaError(404, "not found", "http://example.com/api/v1/test")
|
||||
assert err.status_code == 404
|
||||
assert err.url == "http://example.com/api/v1/test"
|
||||
assert "404" in str(err)
|
||||
assert "not found" in str(err)
|
||||
|
||||
def test_error_is_exception(self):
|
||||
"""GiteaError is a proper exception that can be caught."""
|
||||
with pytest.raises(GiteaError):
|
||||
raise GiteaError(500, "server error")
|
||||
Reference in New Issue
Block a user