import json from collections.abc import AsyncGenerator, Callable, Coroutine from contextlib import asynccontextmanager import httpx import pytest from agentci.integrations.gitea.client import Gitea, GiteaError from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo Handler = Callable[[httpx.Request], Coroutine[None, None, httpx.Response]] @asynccontextmanager async def gitea_client(handler: Handler, *, retries: int = 3) -> AsyncGenerator[Gitea]: client = Gitea( "https://gitea.example/", "secret", retries=retries, transport=httpx.MockTransport(handler), ) try: yield client finally: await client.close() @pytest.fixture def recorded_backoffs(monkeypatch: pytest.MonkeyPatch) -> list[int]: backoffs: list[int] = [] async def record_backoff(delay: int) -> None: backoffs.append(delay) monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", record_backoff) return backoffs async def test_sends_authenticated_json_request_contract() -> None: requests: list[httpx.Request] = [] async def handler(request: httpx.Request) -> httpx.Response: requests.append(request) return httpx.Response(200, json={"id": 17}) async with gitea_client(handler) as client: assert await client.update_comment("org", "repo", 17, "updated status") assert len(requests) == 1 request = requests[0] assert request.method == "PATCH" assert request.url == httpx.URL( "https://gitea.example/api/v1/repos/org/repo/issues/comments/17" ) assert request.headers["authorization"] == "token secret" assert request.headers["accept"] == "application/json" assert request.headers["content-type"] == "application/json" assert json.loads(request.content) == {"body": "updated status"} @pytest.mark.parametrize( ("permission", "expected"), [("write", True), ("ADMIN", True), ("owner", True), ("read", False), (None, False)], ) async def test_maps_repository_permissions(permission: str | None, expected: bool) -> None: async def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == "/api/v1/repos/org/repo/collaborators/alice/permission" return httpx.Response(200, json={"permission": permission}) async with gitea_client(handler) as client: assert await client.has_write_permission("org", "repo", "alice") is expected async def test_maps_issue_and_pull_request_responses() -> None: async def handler(request: httpx.Request) -> httpx.Response: match request.url.path: case "/api/v1/repos/org/repo": return httpx.Response(200, json={"default_branch": "trunk"}) case "/api/v1/repos/org/repo/issues/12": return httpx.Response( 200, json={"title": "Issue title", "body": None, "state": "open"}, ) case "/api/v1/repos/org/repo/pulls/8": return httpx.Response( 200, json={ "title": "Pull title", "body": None, "state": "open", "merged": False, "base": {"ref": "trunk"}, "head": { "ref": "feature", "sha": "abc123", "repo": {"owner": {"login": "fork-owner"}, "name": "fork"}, }, }, ) raise AssertionError(f"unexpected request: {request.url}") async with gitea_client(handler) as client: branch = await client.default_branch("org", "repo") issue = await client.issue("org", "repo", 12) pull = await client.pull_request("org", "repo", 8) assert branch == "trunk" assert issue == IssueInfo(number=12, title="Issue title", body="", state="open") assert pull == PullRequestInfo( number=8, title="Pull title", body="", state="open", merged=False, base_branch="trunk", head_branch="feature", head_sha="abc123", head_owner="fork-owner", head_repo="fork", ) assert pull.is_open async def test_creates_comment_and_pull_request_with_expected_payloads() -> None: requests: list[httpx.Request] = [] async def handler(request: httpx.Request) -> httpx.Response: requests.append(request) if request.url.path.endswith("/issues/4/comments"): return httpx.Response(201, json={"id": "23"}) if request.method == "POST" and request.url.path.endswith("/pulls"): return httpx.Response(201, json={"number": 9}) if request.method == "GET" and request.url.path.endswith("/pulls/9"): return httpx.Response( 200, json={ "title": "Implement it", "body": "Details", "state": "open", "merged": False, "base": {"ref": "main"}, "head": { "ref": "agent/work", "sha": "def456", "repo": {"owner": {"login": "org"}, "name": "repo"}, }, }, ) raise AssertionError(f"unexpected request: {request.method} {request.url}") async with gitea_client(handler) as client: comment_id = await client.create_comment("org", "repo", 4, "Working") pull = await client.create_pull_request( "org", "repo", title="Implement it", body="Details", head="agent/work", base="main", ) assert comment_id == 23 assert pull.number == 9 assert [(request.method, request.url.path) for request in requests] == [ ("POST", "/api/v1/repos/org/repo/issues/4/comments"), ("POST", "/api/v1/repos/org/repo/pulls"), ("GET", "/api/v1/repos/org/repo/pulls/9"), ] assert json.loads(requests[0].content) == {"body": "Working"} assert json.loads(requests[1].content) == { "title": "Implement it", "body": "Details", "head": "agent/work", "base": "main", } async def test_maps_allowed_not_found_responses_without_retry() -> None: paths: list[str] = [] async def handler(request: httpx.Request) -> httpx.Response: paths.append(request.url.path) return httpx.Response(404) async with gitea_client(handler) as client: updated = await client.update_comment("org", "repo", 99, "missing") comments = await client.review_comments("org", "repo", 7, 3) assert not updated assert comments == [] assert paths == [ "/api/v1/repos/org/repo/issues/comments/99", "/api/v1/repos/org/repo/pulls/7/reviews/3/comments", ] @pytest.mark.parametrize(("first_page_size", "expected_pages"), [(0, [1]), (49, [1]), (50, [1, 2])]) async def test_pagination_stops_only_after_a_short_page( first_page_size: int, expected_pages: list[int] ) -> None: pages: list[int] = [] async def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == "/api/v1/repos/org/repo/issues/6/comments" assert request.url.params["limit"] == "50" page = int(request.url.params["page"]) pages.append(page) size = first_page_size if page == 1 else 1 offset = 0 if page == 1 else 50 return httpx.Response( 200, json=[ { "id": offset + index + 1, "user": {"login": f"user-{offset + index + 1}"}, "body": None, "created_at": None, } for index in range(size) ], ) async with gitea_client(handler) as client: comments = await client.issue_comments("org", "repo", 6) expected_count = first_page_size + (1 if first_page_size == 50 else 0) assert pages == expected_pages assert len(comments) == expected_count if comments: assert comments[0] == CommentInfo(id=1, author="user-1", body="", created_at="") assert comments[-1].id == expected_count @pytest.mark.parametrize("status", [400, 401, 403, 404, 422]) async def test_nonretryable_status_fails_once( status: int, recorded_backoffs: list[int], ) -> None: attempts = 0 async def handler(_request: httpx.Request) -> httpx.Response: nonlocal attempts attempts += 1 return httpx.Response(status) async with gitea_client(handler) as client: with pytest.raises( GiteaError, match=rf"Gitea returned {status} for GET /repos/org/repo", ): await client.default_branch("org", "repo") assert attempts == 1 assert recorded_backoffs == [] @pytest.mark.parametrize( ("status", "failures", "expected_backoffs"), [ (429, 1, [1]), (500, 1, [1]), (502, 1, [1]), (503, 1, [1]), (504, 1, [1]), pytest.param(None, 2, [1, 2], id="transport"), ], ) async def test_retryable_failure_recovers_after_exponential_backoff( status: int | None, failures: int, expected_backoffs: list[int], recorded_backoffs: list[int], ) -> None: attempts = 0 async def handler(request: httpx.Request) -> httpx.Response: nonlocal attempts attempts += 1 if attempts <= failures: if status is None: raise httpx.ConnectError("connection refused", request=request) return httpx.Response(status) return httpx.Response(200, json={"default_branch": "main"}) async with gitea_client(handler) as client: assert await client.default_branch("org", "repo") == "main" assert attempts == failures + 1 assert recorded_backoffs == expected_backoffs @pytest.mark.parametrize( ("status", "retries", "expected_message", "expected_cause", "expected_backoffs"), [ pytest.param( 503, 3, "Gitea remained unavailable for GET /repos/org/repo", None, [1, 2], id="status", ), pytest.param( None, 2, "Gitea request failed: GET /repos/org/repo", httpx.ConnectError, [1], id="transport", ), ], ) async def test_retry_exhaustion_reports_failure( status: int | None, retries: int, expected_message: str, expected_cause: type[BaseException] | None, expected_backoffs: list[int], recorded_backoffs: list[int], ) -> None: attempts = 0 async def handler(request: httpx.Request) -> httpx.Response: nonlocal attempts attempts += 1 if status is None: raise httpx.ConnectError("connection refused", request=request) return httpx.Response(status) async with gitea_client(handler, retries=retries) as client: with pytest.raises(GiteaError, match=expected_message) as raised: await client.default_branch("org", "repo") if expected_cause is None: assert raised.value.__cause__ is None else: assert isinstance(raised.value.__cause__, expected_cause) assert attempts == retries assert recorded_backoffs == expected_backoffs