346 lines
12 KiB
Python
346 lines
12 KiB
Python
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()
|
|
|
|
|
|
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.integrations.gitea.client.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.integrations.gitea.client.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.integrations.gitea.client.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.integrations.gitea.client.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.integrations.gitea.client.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]
|