feat: initial homelab-mcp-gitea v0.1
MCP server exposing self-hosted Gitea (gitlab.impresion3d.pro) via stdio JSON-RPC 2.0. Six tools: list_repos, get_repo, get_file, create_issue, list_open_prs, merge_pr. The merge_pr tool pins the Gitea 'do'-field trap (see skill gitea pitfall #13) — sends lowercase 'do', treats HTTP 200 with non-empty body as failure. Stack: FastMCP + httpx + pydantic + uv, with respx-mocked pytest suite (15 tests, 0.69s) and ruff + mypy strict green. Verification: scripts/verify_stdio.py boots the server as a subprocess, exchanges real MCP messages over stdio, and calls list_repos against the live Gitea instance — all green.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
"""Unit tests for the Gitea HTTP client. Uses respx to mock httpx."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from homelab_mcp_gitea.client import GiteaClient, GiteaError
|
||||
|
||||
BASE = "https://gitea.test"
|
||||
TOKEN = "test-token-xyz"
|
||||
|
||||
|
||||
def _client() -> GiteaClient:
|
||||
return GiteaClient(base_url=BASE, token=TOKEN)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_list_repos_returns_parsed_json() -> None:
|
||||
respx.get(f"{BASE}/api/v1/user/repos").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{"full_name": "root/alpha", "private": False},
|
||||
{"full_name": "root/beta", "private": True},
|
||||
],
|
||||
)
|
||||
)
|
||||
async with _client() as c:
|
||||
repos = await c.list_repos(limit=10)
|
||||
assert len(repos) == 2
|
||||
assert repos[0]["full_name"] == "root/alpha"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_get_repo_returns_metadata() -> None:
|
||||
respx.get(f"{BASE}/api/v1/repos/root/alpha").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"full_name": "root/alpha",
|
||||
"description": "test repo",
|
||||
"private": False,
|
||||
"default_branch": "main",
|
||||
"stars_count": 7,
|
||||
"open_issues_count": 2,
|
||||
},
|
||||
)
|
||||
)
|
||||
async with _client() as c:
|
||||
data = await c.get_repo("root", "alpha")
|
||||
assert data["stars_count"] == 7
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_get_file_decodes_base64_content() -> None:
|
||||
raw = "hello world\n"
|
||||
encoded = base64.b64encode(raw.encode()).decode()
|
||||
respx.get(f"{BASE}/api/v1/repos/root/alpha/contents/README.md").mock(
|
||||
return_value=httpx.Response(200, json={"content": encoded})
|
||||
)
|
||||
async with _client() as c:
|
||||
text = await c.get_file("root", "alpha", "README.md")
|
||||
assert text == raw
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_create_issue_posts_payload_and_returns_issue() -> None:
|
||||
respx.post(f"{BASE}/api/v1/repos/root/alpha/issues").mock(
|
||||
return_value=httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"number": 42,
|
||||
"title": "bug",
|
||||
"html_url": "https://gitea.test/root/alpha/issues/42",
|
||||
},
|
||||
)
|
||||
)
|
||||
async with _client() as c:
|
||||
issue = await c.create_issue("root", "alpha", "bug", "details")
|
||||
assert issue["number"] == 42
|
||||
sent = respx.calls.last.request
|
||||
import json
|
||||
|
||||
payload = json.loads(sent.content)
|
||||
assert payload == {"title": "bug", "body": "details"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_list_open_prs_filters_by_state() -> None:
|
||||
respx.get(f"{BASE}/api/v1/repos/root/alpha/pulls").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"number": 5,
|
||||
"title": "feat: x",
|
||||
"user": {"login": "alice"},
|
||||
"base": {"ref": "main"},
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
async with _client() as c:
|
||||
prs = await c.list_open_prs("root", "alpha")
|
||||
assert prs[0]["number"] == 5
|
||||
sent = respx.calls.last.request
|
||||
assert sent.url.params["state"] == "open"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_merge_pr_uses_lowercase_do_field() -> None:
|
||||
"""Trap regression: Gitea needs the JSON key `do`, not `Do` or `Merge_Method`."""
|
||||
respx.post(f"{BASE}/api/v1/repos/root/alpha/pulls/5/merge").mock(
|
||||
return_value=httpx.Response(200, content=b"")
|
||||
)
|
||||
async with _client() as c:
|
||||
ok = await c.merge_pr("root", "alpha", 5)
|
||||
assert ok is True
|
||||
import json
|
||||
|
||||
sent_payload = json.loads(respx.calls.last.request.content)
|
||||
assert sent_payload == {"do": "merge"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_merge_pr_raises_on_nonempty_body_even_at_200() -> None:
|
||||
"""The other half of the trap: status 200 with a body means failure."""
|
||||
respx.post(f"{BASE}/api/v1/repos/root/alpha/pulls/5/merge").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"message": "[Do]: Required"},
|
||||
)
|
||||
)
|
||||
async with _client() as c:
|
||||
with pytest.raises(GiteaError) as exc_info:
|
||||
await c.merge_pr("root", "alpha", 5)
|
||||
assert "do" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_http_error_raises_gitea_error() -> None:
|
||||
respx.get(f"{BASE}/api/v1/repos/root/missing").mock(
|
||||
return_value=httpx.Response(404, json={"message": "not found"})
|
||||
)
|
||||
async with _client() as c:
|
||||
with pytest.raises(GiteaError) as exc_info:
|
||||
await c.get_repo("root", "missing")
|
||||
assert exc_info.value.status == 404
|
||||
|
||||
|
||||
def test_constructor_requires_url_and_token() -> None:
|
||||
import os
|
||||
|
||||
os.environ.pop("GITEA_URL", None)
|
||||
os.environ.pop("GITEA_TOKEN", None)
|
||||
with pytest.raises(ValueError, match="GITEA_URL"):
|
||||
GiteaClient(token="t")
|
||||
with pytest.raises(ValueError, match="GITEA_TOKEN"):
|
||||
GiteaClient(base_url=BASE)
|
||||
Reference in New Issue
Block a user