blob: 0f5d941e1502de4cccf4cbc258d98b757e701133 [file]
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
from urllib.parse import quote
import pytest
from gerrit_mcp_server_code_owners.extension import register
# ---------------------------------------------------------------------------
# Test fixtures and helpers
# ---------------------------------------------------------------------------
BASE_URL = "https://gerrit.example.com/a"
@pytest.fixture
def ctx(tmp_path: Path):
mock_ctx = MagicMock()
mock_ctx.get_base_url = MagicMock(return_value=BASE_URL)
mock_ctx.normalize_url = lambda u: u
mock_ctx.run_curl = AsyncMock()
mock_ctx.load_config = MagicMock(return_value={})
mock_ctx.log_path = tmp_path / "server.log"
mock_ctx.plugin_registry = MagicMock()
mock_ctx.plugin_registry.host_has_plugin = AsyncMock(return_value=True)
mock_ctx.extension_config = MagicMock(return_value={})
return mock_ctx
def _capture_tools(ctx) -> dict:
"""Replace ctx.mcp.tool with a capturing decorator; return registered fns."""
registered = {}
def capture_decorator():
def decorator(fn):
registered[fn.__name__] = fn
return fn
return decorator
ctx.mcp.tool = MagicMock(side_effect=lambda: capture_decorator())
register(ctx)
return registered
# ---------------------------------------------------------------------------
# register()
# ---------------------------------------------------------------------------
class TestRegister:
def test_registers_three_tools(self, ctx):
register(ctx)
assert ctx.mcp.tool.call_count == 3
# ---------------------------------------------------------------------------
# get_code_owner_status
# ---------------------------------------------------------------------------
class TestGetCodeOwnerStatus:
def _make_status_response(self, patch_set=1, files=None, more=False):
data = {
"patch_set_number": patch_set,
"file_code_owner_statuses": files or [],
}
if more:
data["more"] = True
return json.dumps(data)
@pytest.mark.asyncio
async def test_approved_file(self, ctx):
ctx.run_curl = AsyncMock(
return_value=self._make_status_response(
patch_set=2,
files=[
{
"change_type": "MODIFIED",
"new_path_status": {
"path": "src/foo.py",
"status": "APPROVED",
},
}
],
)
)
fn = _capture_tools(ctx)["get_code_owner_status"]
data = await fn(change_id="100", gerrit_base_url=BASE_URL)
assert data["change_id"] == "100"
assert data["patch_set_number"] == 2
assert len(data["file_code_owner_statuses"]) == 1
status = data["file_code_owner_statuses"][0]
assert status["change_type"] == "MODIFIED"
assert status["new_path_status"]["path"] == "src/foo.py"
assert status["new_path_status"]["status"] == "APPROVED"
@pytest.mark.asyncio
async def test_pending_file_with_reasons(self, ctx):
ctx.run_curl = AsyncMock(
return_value=self._make_status_response(
files=[
{
"new_path_status": {
"path": "src/bar.py",
"status": "PENDING",
"reasons": ["<GERRIT_ACCOUNT_1> is a reviewer"],
},
}
],
)
)
fn = _capture_tools(ctx)["get_code_owner_status"]
data = await fn(change_id="200", gerrit_base_url=BASE_URL)
path_status = data["file_code_owner_statuses"][0]["new_path_status"]
assert path_status["status"] == "PENDING"
assert len(path_status["reasons"]) == 1
@pytest.mark.asyncio
async def test_renamed_file_has_old_and_new_path(self, ctx):
ctx.run_curl = AsyncMock(
return_value=self._make_status_response(
files=[
{
"change_type": "RENAMED",
"old_path_status": {
"path": "old/path.py",
"status": "APPROVED",
},
"new_path_status": {
"path": "new/path.py",
"status": "APPROVED",
},
}
],
)
)
fn = _capture_tools(ctx)["get_code_owner_status"]
data = await fn(change_id="300", gerrit_base_url=BASE_URL)
entry = data["file_code_owner_statuses"][0]
assert entry["old_path_status"]["path"] == "old/path.py"
assert entry["new_path_status"]["path"] == "new/path.py"
@pytest.mark.asyncio
async def test_more_flag_included_when_true(self, ctx):
ctx.run_curl = AsyncMock(return_value=self._make_status_response(more=True))
fn = _capture_tools(ctx)["get_code_owner_status"]
data = await fn(change_id="400", gerrit_base_url=BASE_URL)
assert data.get("more") is True
@pytest.mark.asyncio
async def test_more_flag_absent_when_false(self, ctx):
ctx.run_curl = AsyncMock(return_value=self._make_status_response(more=False))
fn = _capture_tools(ctx)["get_code_owner_status"]
data = await fn(change_id="400", gerrit_base_url=BASE_URL)
assert data.get("more") is None
@pytest.mark.asyncio
async def test_limit_and_start_in_url(self, ctx):
ctx.run_curl = AsyncMock(return_value=self._make_status_response())
fn = _capture_tools(ctx)["get_code_owner_status"]
await fn(change_id="500", limit=10, start=5, gerrit_base_url=BASE_URL)
called_url = ctx.run_curl.call_args[0][0][0]
assert "n=10" in called_url
assert "S=5" in called_url
@pytest.mark.asyncio
async def test_invalid_json_raises(self, ctx):
ctx.run_curl = AsyncMock(return_value="not json")
fn = _capture_tools(ctx)["get_code_owner_status"]
with pytest.raises(ValueError, match="Could not parse"):
await fn(change_id="999", gerrit_base_url=BASE_URL)
@pytest.mark.asyncio
async def test_accounts_populated_when_present(self, ctx):
data = {
"patch_set_number": 1,
"file_code_owner_statuses": [
{
"new_path_status": {
"path": "src/foo.py",
"status": "PENDING",
"reasons": ["<GERRIT_ACCOUNT_42> is a reviewer"],
}
}
],
"accounts": {
"42": {
"_account_id": 42,
"name": "Alice",
"email": "alice@example.com",
"username": "alice",
}
},
}
ctx.run_curl = AsyncMock(return_value=json.dumps(data))
fn = _capture_tools(ctx)["get_code_owner_status"]
result = await fn(change_id="600", gerrit_base_url=BASE_URL)
assert result["accounts"] is not None
assert "42" in result["accounts"]
assert result["accounts"]["42"]["account_id"] == 42
assert result["accounts"]["42"]["name"] == "Alice"
@pytest.mark.asyncio
async def test_accounts_absent_when_not_in_response(self, ctx):
ctx.run_curl = AsyncMock(return_value=self._make_status_response())
fn = _capture_tools(ctx)["get_code_owner_status"]
result = await fn(change_id="700", gerrit_base_url=BASE_URL)
assert result["accounts"] is None
# ---------------------------------------------------------------------------
# get_code_owners_for_path
# ---------------------------------------------------------------------------
class TestGetCodeOwnersForPath:
def _make_owners_response(self, owners=None, owned_by_all=False):
data = {"code_owners": owners or []}
if owned_by_all:
data["owned_by_all_users"] = True
return json.dumps(data)
@pytest.mark.asyncio
async def test_returns_owners(self, ctx):
ctx.run_curl = AsyncMock(
return_value=self._make_owners_response(
owners=[
{
"account": {
"_account_id": 42,
"name": "Alice",
"email": "alice@example.com",
},
"scorings": {"IS_REVIEWER": 1},
}
]
)
)
fn = _capture_tools(ctx)["get_code_owners_for_path"]
data = await fn(
change_id="100",
path="src/foo.py",
gerrit_base_url=BASE_URL,
)
assert data["change_id"] == "100"
assert data["path"] == "src/foo.py"
assert data["revision_id"] == "current"
assert len(data["code_owners"]) == 1
owner = data["code_owners"][0]
assert owner["account"]["account_id"] == 42
assert owner["account"]["name"] == "Alice"
assert owner["scorings"] == {"IS_REVIEWER": 1}
@pytest.mark.asyncio
async def test_owned_by_all_users(self, ctx):
ctx.run_curl = AsyncMock(
return_value=self._make_owners_response(owned_by_all=True)
)
fn = _capture_tools(ctx)["get_code_owners_for_path"]
data = await fn(
change_id="100",
path="OWNERS",
gerrit_base_url=BASE_URL,
)
assert data.get("owned_by_all_users") is True
@pytest.mark.asyncio
async def test_owned_by_all_users_absent_when_false(self, ctx):
ctx.run_curl = AsyncMock(return_value=self._make_owners_response())
fn = _capture_tools(ctx)["get_code_owners_for_path"]
data = await fn(
change_id="100",
path="src/foo.py",
gerrit_base_url=BASE_URL,
)
assert data.get("owned_by_all_users") is None
@pytest.mark.asyncio
async def test_url_contains_path(self, ctx):
ctx.run_curl = AsyncMock(return_value=self._make_owners_response())
fn = _capture_tools(ctx)["get_code_owners_for_path"]
await fn(
change_id="100",
path="src/my file.py",
revision_id="abc123",
gerrit_base_url=BASE_URL,
)
called_url = ctx.run_curl.call_args[0][0][0]
assert "abc123" in called_url
assert "src/my%20file.py" in called_url
@pytest.mark.asyncio
async def test_leading_slash_stripped_from_path(self, ctx):
ctx.run_curl = AsyncMock(return_value=self._make_owners_response())
fn = _capture_tools(ctx)["get_code_owners_for_path"]
await fn(
change_id="100",
path="/src/foo.py",
gerrit_base_url=BASE_URL,
)
called_url = ctx.run_curl.call_args[0][0][0]
assert "//src" not in called_url
@pytest.mark.asyncio
async def test_limit_in_url(self, ctx):
ctx.run_curl = AsyncMock(return_value=self._make_owners_response())
fn = _capture_tools(ctx)["get_code_owners_for_path"]
await fn(
change_id="100",
path="src/foo.py",
limit=5,
gerrit_base_url=BASE_URL,
)
called_url = ctx.run_curl.call_args[0][0][0]
assert "n=5" in called_url
@pytest.mark.asyncio
async def test_invalid_json_raises(self, ctx):
ctx.run_curl = AsyncMock(return_value="not json")
fn = _capture_tools(ctx)["get_code_owners_for_path"]
with pytest.raises(ValueError, match="Could not parse"):
await fn(change_id="100", path="src/foo.py", gerrit_base_url=BASE_URL)
# ---------------------------------------------------------------------------
# check_code_owner
# ---------------------------------------------------------------------------
class TestCheckCodeOwner:
def _make_check_response(self, is_code_owner=True, is_resolvable=True, **kwargs):
data = {
"is_code_owner": is_code_owner,
"is_resolvable": is_resolvable,
}
data.update(kwargs)
return json.dumps(data)
@pytest.mark.asyncio
async def test_is_code_owner(self, ctx):
ctx.run_curl = AsyncMock(
return_value=self._make_check_response(
is_code_owner=True,
is_resolvable=True,
can_read_ref=True,
can_approve_change=True,
)
)
fn = _capture_tools(ctx)["check_code_owner"]
data = await fn(
project="my/project",
branch="main",
path="src/foo.py",
email="alice@example.com",
gerrit_base_url=BASE_URL,
)
assert data["project"] == "my/project"
assert data["branch"] == "main"
assert data["path"] == "src/foo.py"
assert data["email"] == "alice@example.com"
assert data["is_code_owner"] is True
assert data["is_resolvable"] is True
assert data["can_read_ref"] is True
assert data["can_approve_change"] is True
@pytest.mark.asyncio
async def test_not_code_owner(self, ctx):
ctx.run_curl = AsyncMock(
return_value=self._make_check_response(
is_code_owner=False,
is_resolvable=True,
)
)
fn = _capture_tools(ctx)["check_code_owner"]
data = await fn(
project="my/project",
branch="main",
path="src/bar.py",
email="bob@example.com",
gerrit_base_url=BASE_URL,
)
assert data["is_code_owner"] is False
@pytest.mark.asyncio
async def test_fallback_owner_fields(self, ctx):
ctx.run_curl = AsyncMock(
return_value=self._make_check_response(
is_fallback_code_owner=True,
is_global_code_owner=False,
is_default_code_owner=False,
annotation=["FALLBACK_CODE_OWNER"],
)
)
fn = _capture_tools(ctx)["check_code_owner"]
data = await fn(
project="my/project",
branch="main",
path="src/baz.py",
email="charlie@example.com",
gerrit_base_url=BASE_URL,
)
assert data["is_fallback_code_owner"] is True
assert data["annotation"] == ["FALLBACK_CODE_OWNER"]
@pytest.mark.asyncio
async def test_url_contains_email_and_path(self, ctx):
ctx.run_curl = AsyncMock(return_value=self._make_check_response())
fn = _capture_tools(ctx)["check_code_owner"]
await fn(
project="my/project",
branch="main",
path="src/foo.py",
email="alice@example.com",
gerrit_base_url=BASE_URL,
)
called_url = ctx.run_curl.call_args[0][0][0]
assert quote("alice@example.com") in called_url
assert quote("src/foo.py") in called_url
assert "code_owners.check" in called_url
@pytest.mark.asyncio
async def test_change_id_appended_to_url(self, ctx):
ctx.run_curl = AsyncMock(return_value=self._make_check_response())
fn = _capture_tools(ctx)["check_code_owner"]
await fn(
project="my/project",
branch="main",
path="src/foo.py",
email="alice@example.com",
change_id="123",
gerrit_base_url=BASE_URL,
)
called_url = ctx.run_curl.call_args[0][0][0]
assert "change=123" in called_url
@pytest.mark.asyncio
async def test_invalid_json_raises(self, ctx):
ctx.run_curl = AsyncMock(return_value="not json")
fn = _capture_tools(ctx)["check_code_owner"]
with pytest.raises(ValueError, match="Could not parse"):
await fn(
project="p",
branch="b",
path="f",
email="e@x.com",
gerrit_base_url=BASE_URL,
)