diff --git a/agent/utils/gitea_client.py b/agent/utils/gitea_client.py index 838f4d2..fd2c37f 100644 --- a/agent/utils/gitea_client.py +++ b/agent/utils/gitea_client.py @@ -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 diff --git a/tests/test_gitea_client.py b/tests/test_gitea_client.py new file mode 100644 index 0000000..762236d --- /dev/null +++ b/tests/test_gitea_client.py @@ -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()