feat: scope list_repositories to the authenticated user in service-PAT mode
docker / test (push) Successful in 28s
docker / lint (push) Successful in 33s
lint / lint (push) Successful in 35s
test / test (push) Successful in 33s
docker / docker-test (push) Successful in 10s
docker / docker-publish (push) Successful in 6s

Previously list_repositories was blocked in service-PAT mode because it has no
repository target for the per-user permission check, so users could not list
their repositories at all (the connector surfaced a generic error).

list_repositories now returns only the repositories the signed-in user owns or
contributes to, instead of everything the bot token can see:
- gitea_client.py: add list_user_repositories(login) — resolves the user id and
  queries /api/v1/repos/search with the uid filter.
- repository.py: list_repositories_tool uses the user-scoped path when a service
  PAT is configured and a user login is present; pure-OAuth mode still uses the
  user's own /user/repos.
- server.py: allow list_repositories through the service-PAT guard (it is scoped
  to the user in the handler); all other tools still require a repository target.
- README.md: document the new user-scoped behavior and its visibility caveat.

Tests: user-scoped client method (uid resolution + unknown user), PAT-mode tool
scoping, and conftest now clears the request context between tests to prevent
contextvar login leakage across files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 17:07:19 +02:00
parent 624a3c79ee
commit e873d0325b
7 changed files with 146 additions and 20 deletions
+33
View File
@@ -213,6 +213,39 @@ def test_compare_refs_args_reject_traversal_head(value: str) -> None:
CompareRefsArgs(owner="o", repo="r", base="main", head=value)
@pytest.mark.asyncio
async def test_list_user_repositories_scopes_by_uid() -> None:
"""User-scoped listing resolves the uid and filters repo search by it."""
client = GiteaClient(token="service-pat")
captured: dict = {}
async def fake_request(method: str, endpoint: str, **kwargs):
if endpoint == "/api/v1/users/alice":
return {"id": 7, "login": "alice"}
if endpoint == "/api/v1/repos/search":
captured["params"] = kwargs.get("params")
return {"ok": True, "data": [{"full_name": "alice/demo"}, "not-a-dict"]}
return {}
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
repos = await client.list_user_repositories("alice")
assert captured["params"]["uid"] == 7
assert repos == [{"full_name": "alice/demo"}]
@pytest.mark.asyncio
async def test_list_user_repositories_unknown_user_returns_empty() -> None:
"""A user that cannot be resolved yields an empty list, not an error."""
client = GiteaClient(token="service-pat")
async def fake_request(method: str, endpoint: str, **kwargs):
return {} # no id field
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
assert await client.list_user_repositories("ghost") == []
def test_git_refs_allow_slash_containing_refs() -> None:
"""Legitimate refs that contain '/' validate successfully."""
tree = FileTreeArgs(owner="o", repo="r", ref="feature/foo")