feat: implement GiteaClient with Gitea REST API v1
Implemented full async Gitea REST API v1 client using httpx with the following methods: - create_pull_request: Create PRs with title, head, base, and body - merge_pull_request: Merge PRs with configurable merge type - create_issue_comment: Post comments on issues/PRs - get_issue: Fetch issue/PR details - get_issue_comments: Retrieve all comments for an issue/PR - create_branch: Create new branches from existing ones Added lazy singleton pattern with get_gitea_client() factory function that reads GITEA_URL and GITEA_TOKEN from environment. All methods properly call raise_for_status() and return JSON responses. Comprehensive test suite with 8 tests covering all methods plus error handling.
This commit is contained in:
parent
5d44c2e7e2
commit
b2ad726fc4
@ -1,5 +1,6 @@
|
||||
"""Gitea REST API v1 client. Phase 2 implementation."""
|
||||
|
||||
import os
|
||||
import httpx
|
||||
|
||||
|
||||
@ -13,22 +14,133 @@ class GiteaClient:
|
||||
)
|
||||
|
||||
async def create_pull_request(self, owner, repo, title, head, base, body) -> dict:
|
||||
raise NotImplementedError("Phase 2")
|
||||
"""Create a pull request.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
title: PR title
|
||||
head: Head branch name
|
||||
base: Base branch name
|
||||
body: PR body/description
|
||||
|
||||
Returns:
|
||||
dict: Created PR data (number, html_url, etc.)
|
||||
"""
|
||||
resp = await self._client.post(
|
||||
f"/repos/{owner}/{repo}/pulls",
|
||||
json={"title": title, "head": head, "base": base, "body": body},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def merge_pull_request(self, owner, repo, pr_number, merge_type="merge") -> dict:
|
||||
raise NotImplementedError("Phase 2")
|
||||
"""Merge a pull request.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
pr_number: PR number
|
||||
merge_type: Merge type ("merge", "rebase", "squash")
|
||||
|
||||
Returns:
|
||||
dict: Merge result
|
||||
"""
|
||||
resp = await self._client.post(
|
||||
f"/repos/{owner}/{repo}/pulls/{pr_number}/merge",
|
||||
json={"Do": merge_type},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def create_issue_comment(self, owner, repo, issue_number, body) -> dict:
|
||||
raise NotImplementedError("Phase 2")
|
||||
"""Create a comment on an issue or PR.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
issue_number: Issue or PR number
|
||||
body: Comment body
|
||||
|
||||
Returns:
|
||||
dict: Created comment data (id, body, etc.)
|
||||
"""
|
||||
resp = await self._client.post(
|
||||
f"/repos/{owner}/{repo}/issues/{issue_number}/comments",
|
||||
json={"body": body},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_issue(self, owner, repo, issue_number) -> dict:
|
||||
raise NotImplementedError("Phase 2")
|
||||
"""Get issue or PR details.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
issue_number: Issue or PR number
|
||||
|
||||
Returns:
|
||||
dict: Issue/PR data (number, title, body, etc.)
|
||||
"""
|
||||
resp = await self._client.get(f"/repos/{owner}/{repo}/issues/{issue_number}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_issue_comments(self, owner, repo, issue_number) -> list:
|
||||
raise NotImplementedError("Phase 2")
|
||||
"""Get all comments on an issue or PR.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
issue_number: Issue or PR number
|
||||
|
||||
Returns:
|
||||
list: List of comment dicts
|
||||
"""
|
||||
resp = await self._client.get(
|
||||
f"/repos/{owner}/{repo}/issues/{issue_number}/comments"
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def create_branch(self, owner, repo, branch_name, old_branch) -> dict:
|
||||
raise NotImplementedError("Phase 2")
|
||||
"""Create a new branch.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
branch_name: New branch name
|
||||
old_branch: Source branch name
|
||||
|
||||
Returns:
|
||||
dict: Created branch data
|
||||
"""
|
||||
resp = await self._client.post(
|
||||
f"/repos/{owner}/{repo}/branches",
|
||||
json={"new_branch_name": branch_name, "old_branch_name": old_branch},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def close(self):
|
||||
await self._client.aclose()
|
||||
|
||||
|
||||
# Lazy singleton
|
||||
_client: GiteaClient | None = None
|
||||
|
||||
|
||||
def get_gitea_client() -> GiteaClient:
|
||||
"""Get or create the singleton GiteaClient instance.
|
||||
|
||||
Returns:
|
||||
GiteaClient: The singleton instance
|
||||
"""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = GiteaClient(
|
||||
base_url=os.environ.get("GITEA_URL", "http://gitea:3000"),
|
||||
token=os.environ.get("GITEA_TOKEN", ""),
|
||||
)
|
||||
return _client
|
||||
|
||||
87
tests/test_gitea_client.py
Normal file
87
tests/test_gitea_client.py
Normal file
@ -0,0 +1,87 @@
|
||||
import pytest
|
||||
import httpx
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from agent.utils.gitea_client import GiteaClient
|
||||
|
||||
@pytest.fixture
|
||||
def gitea_client():
|
||||
return GiteaClient(base_url="http://gitea:3000", token="test-token")
|
||||
|
||||
@pytest.fixture
|
||||
def mock_response():
|
||||
def _make(status_code=200, json_data=None):
|
||||
resp = MagicMock(spec=httpx.Response)
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = json_data or {}
|
||||
resp.raise_for_status = MagicMock()
|
||||
if status_code >= 400:
|
||||
resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"error", request=MagicMock(), response=resp
|
||||
)
|
||||
return resp
|
||||
return _make
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pull_request(gitea_client, mock_response):
|
||||
pr_data = {"number": 1, "html_url": "http://gitea:3000/quant/galaxis-po/pulls/1"}
|
||||
gitea_client._client.post = AsyncMock(return_value=mock_response(201, pr_data))
|
||||
result = await gitea_client.create_pull_request(
|
||||
owner="quant", repo="galaxis-po", title="feat: add feature",
|
||||
head="galaxis-agent/abc123", base="main", body="PR body",
|
||||
)
|
||||
assert result["number"] == 1
|
||||
call_url = gitea_client._client.post.call_args[0][0]
|
||||
assert "/repos/quant/galaxis-po/pulls" in call_url
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_issue_comment(gitea_client, mock_response):
|
||||
comment_data = {"id": 42, "body": "작업을 시작합니다."}
|
||||
gitea_client._client.post = AsyncMock(return_value=mock_response(201, comment_data))
|
||||
result = await gitea_client.create_issue_comment(
|
||||
owner="quant", repo="galaxis-po", issue_number=1, body="작업을 시작합니다."
|
||||
)
|
||||
assert result["id"] == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_issue(gitea_client, mock_response):
|
||||
issue_data = {"number": 1, "title": "Fix bug", "body": "Bug description"}
|
||||
gitea_client._client.get = AsyncMock(return_value=mock_response(200, issue_data))
|
||||
result = await gitea_client.get_issue(owner="quant", repo="galaxis-po", issue_number=1)
|
||||
assert result["title"] == "Fix bug"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_issue_comments(gitea_client, mock_response):
|
||||
comments = [{"id": 1, "body": "comment1"}, {"id": 2, "body": "comment2"}]
|
||||
gitea_client._client.get = AsyncMock(return_value=mock_response(200, comments))
|
||||
result = await gitea_client.get_issue_comments(owner="quant", repo="galaxis-po", issue_number=1)
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_pull_request(gitea_client, mock_response):
|
||||
gitea_client._client.post = AsyncMock(return_value=mock_response(200, {}))
|
||||
await gitea_client.merge_pull_request(owner="quant", repo="galaxis-po", pr_number=1, merge_type="merge")
|
||||
call_url = gitea_client._client.post.call_args[0][0]
|
||||
assert "/pulls/1/merge" in call_url
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch(gitea_client, mock_response):
|
||||
branch_data = {"name": "galaxis-agent/abc123"}
|
||||
gitea_client._client.post = AsyncMock(return_value=mock_response(201, branch_data))
|
||||
result = await gitea_client.create_branch(
|
||||
owner="quant", repo="galaxis-po", branch_name="galaxis-agent/abc123", old_branch="main",
|
||||
)
|
||||
assert result["name"] == "galaxis-agent/abc123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_error_raises_exception(gitea_client, mock_response):
|
||||
gitea_client._client.post = AsyncMock(return_value=mock_response(404))
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await gitea_client.create_pull_request(
|
||||
owner="quant", repo="galaxis-po", title="t", head="h", base="b", body=""
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_close(gitea_client):
|
||||
gitea_client._client.aclose = AsyncMock()
|
||||
await gitea_client.close()
|
||||
gitea_client._client.aclose.assert_called_once()
|
||||
Loading…
x
Reference in New Issue
Block a user