rewrite phase 1
This commit is contained in:
+334
-12
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user