rewrite phase 1

This commit is contained in:
2026-07-22 23:10:23 +02:00
parent 7527831af6
commit 98ac4abca1
89 changed files with 9179 additions and 2795 deletions
+334 -12
View File
@@ -1,22 +1,344 @@
import json
from collections.abc import AsyncIterator, Callable, Coroutine
from contextlib import asynccontextmanager
import httpx
import respx
import pytest
from agentci.adapters.gitea import GiteaClient
from agentci.gitea import CommentInfo, Gitea, GiteaError, IssueInfo, PullRequestInfo
Handler = Callable[[httpx.Request], Coroutine[None, None, httpx.Response]]
@respx.mock
async def test_updates_issue_comment_by_id() -> None:
route = respx.patch(
"https://gitea.example/api/v1/repos/org/repo/issues/comments/17"
).mock(return_value=httpx.Response(200, json={"id": 17}))
client = GiteaClient("https://gitea.example", "secret")
@asynccontextmanager
async def gitea_client(handler: Handler, *, retries: int = 3) -> AsyncIterator[Gitea]:
client = Gitea(
"https://gitea.example/",
"secret",
retries=retries,
transport=httpx.MockTransport(handler),
)
try:
await client.update_comment("org", "repo", 17, "updated status")
yield client
finally:
await client.close()
assert route.called
assert json.loads(route.calls[0].request.content) == {"body": "updated status"}
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, monkeypatch: pytest.MonkeyPatch) -> None:
attempts = 0
sleeps: list[int] = []
async def handler(_request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
return httpx.Response(status)
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
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 sleeps == []
@pytest.mark.parametrize("status", [429, 500, 502, 503, 504])
async def test_retryable_status_recovers_after_backoff(
status: int, monkeypatch: pytest.MonkeyPatch
) -> None:
attempts = 0
sleeps: list[int] = []
async def handler(_request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
if attempts == 1:
return httpx.Response(status)
return httpx.Response(200, json={"default_branch": "main"})
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
async with gitea_client(handler) as client:
assert await client.default_branch("org", "repo") == "main"
assert attempts == 2
assert sleeps == [1]
async def test_retryable_status_exhaustion_uses_exponential_backoff(
monkeypatch: pytest.MonkeyPatch,
) -> None:
attempts = 0
sleeps: list[int] = []
async def handler(_request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
return httpx.Response(503)
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
async with gitea_client(handler) as client:
with pytest.raises(
GiteaError,
match="Gitea remained unavailable for GET /repos/org/repo",
):
await client.default_branch("org", "repo")
assert attempts == 3
assert sleeps == [1, 2]
async def test_transport_failure_retries_and_recovers(monkeypatch: pytest.MonkeyPatch) -> None:
attempts = 0
sleeps: list[int] = []
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
if attempts < 3:
raise httpx.ConnectError("connection refused", request=request)
return httpx.Response(200, json={"default_branch": "main"})
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
async with gitea_client(handler) as client:
assert await client.default_branch("org", "repo") == "main"
assert attempts == 3
assert sleeps == [1, 2]
async def test_transport_failure_exhaustion_preserves_cause(
monkeypatch: pytest.MonkeyPatch,
) -> None:
attempts = 0
sleeps: list[int] = []
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
raise httpx.ConnectError("connection refused", request=request)
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
async with gitea_client(handler, retries=2) as client:
with pytest.raises(
GiteaError,
match="Gitea request failed: GET /repos/org/repo",
) as raised:
await client.default_branch("org", "repo")
assert isinstance(raised.value.__cause__, httpx.ConnectError)
assert attempts == 2
assert sleeps == [1]