From e8983d85348d32f5ecf1188e198e34b1e785f38c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A8=B8=EB=8B=88=ED=8E=98=EB=8B=88?= Date: Fri, 20 Mar 2026 17:39:45 +0900 Subject: [PATCH] feat: implement gitea_comment and discord_reply tools --- agent/tools/discord_reply.py | 33 +++++++++++++++++++++--- agent/tools/gitea_comment.py | 41 +++++++++++++++++++++++++++--- agent/utils/discord_client.py | 46 +++++++++++++++++++++++++++++---- tests/test_discord_reply.py | 48 +++++++++++++++++++++++++++++++++++ tests/test_gitea_comment.py | 48 +++++++++++++++++++++++++++++++++++ 5 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 tests/test_discord_reply.py create mode 100644 tests/test_gitea_comment.py diff --git a/agent/tools/discord_reply.py b/agent/tools/discord_reply.py index aef9329..9f6b333 100644 --- a/agent/tools/discord_reply.py +++ b/agent/tools/discord_reply.py @@ -1,5 +1,32 @@ -"""Discord message tool. Phase 2 implementation.""" +"""Discord 채널/스레드 메시지 전송 도구.""" +from __future__ import annotations + +import asyncio +import logging +import os +from typing import Any + +from agent.utils.discord_client import get_discord_client + +logger = logging.getLogger(__name__) -def discord_reply(message: str) -> dict: - raise NotImplementedError("Phase 2") +def discord_reply(message: str) -> dict[str, Any]: + if not message.strip(): + return {"success": False, "error": "빈 메시지는 전송할 수 없습니다."} + + channel_id = os.environ.get("DISCORD_CHANNEL_ID", "") + if not channel_id: + return {"success": False, "error": "DISCORD_CHANNEL_ID가 설정되지 않았습니다."} + + client = get_discord_client() + + try: + result = asyncio.run( + client.send_message(channel_id=channel_id, content=message) + ) + logger.info("Sent Discord message to channel %s", channel_id) + return {"success": True, "message_id": result.get("id")} + except Exception as e: + logger.exception("Failed to send Discord message") + return {"success": False, "error": str(e)} diff --git a/agent/tools/gitea_comment.py b/agent/tools/gitea_comment.py index ef1406b..f6b2b49 100644 --- a/agent/tools/gitea_comment.py +++ b/agent/tools/gitea_comment.py @@ -1,5 +1,40 @@ -"""Gitea issue/PR comment tool. Phase 2 implementation.""" +"""Gitea 이슈/PR 코멘트 작성 도구.""" +from __future__ import annotations + +import asyncio +import logging +import os +from typing import Any + +from agent.utils.gitea_client import get_gitea_client + +logger = logging.getLogger(__name__) -def gitea_comment(message: str, issue_number: int) -> dict: - raise NotImplementedError("Phase 2") +def _get_repo_info() -> tuple[str, str]: + owner = os.environ.get("DEFAULT_REPO_OWNER", "quant") + repo = os.environ.get("DEFAULT_REPO_NAME", "galaxis-po") + return owner, repo + + +def gitea_comment(message: str, issue_number: int) -> dict[str, Any]: + if not issue_number or issue_number <= 0: + return {"success": False, "error": "유효한 issue_number가 필요합니다."} + + if not message.strip(): + return {"success": False, "error": "빈 메시지는 작성할 수 없습니다."} + + owner, repo = _get_repo_info() + client = get_gitea_client() + + try: + result = asyncio.run( + client.create_issue_comment( + owner=owner, repo=repo, issue_number=issue_number, body=message + ) + ) + logger.info("Posted comment on %s/%s#%d", owner, repo, issue_number) + return {"success": True, "comment_id": result.get("id")} + except Exception as e: + logger.exception("Failed to post comment on %s/%s#%d", owner, repo, issue_number) + return {"success": False, "error": str(e)} diff --git a/agent/utils/discord_client.py b/agent/utils/discord_client.py index ec86331..b80f1ef 100644 --- a/agent/utils/discord_client.py +++ b/agent/utils/discord_client.py @@ -1,9 +1,45 @@ -"""Discord bot integration. Phase 2 implementation.""" +"""Discord REST API 클라이언트.""" +from __future__ import annotations + +import logging +import os + +import httpx + +logger = logging.getLogger(__name__) + +DISCORD_API_BASE = "https://discord.com/api/v10" class DiscordClient: - async def send_message(self, channel_id: str, content: str) -> dict: - raise NotImplementedError("Phase 2") + def __init__(self, token: str): + self.token = token + self._client = httpx.AsyncClient( + base_url=DISCORD_API_BASE, + headers={"Authorization": f"Bot {self.token}"}, + timeout=15.0, + ) - async def send_thread_reply(self, channel_id, thread_id, content) -> dict: - raise NotImplementedError("Phase 2") + async def send_message(self, channel_id: str, content: str) -> dict: + resp = await self._client.post( + f"/channels/{channel_id}/messages", + json={"content": content}, + ) + resp.raise_for_status() + return resp.json() + + async def send_thread_reply(self, channel_id: str, thread_id: str, content: str) -> dict: + return await self.send_message(channel_id=thread_id, content=content) + + async def close(self): + await self._client.aclose() + + +_client: DiscordClient | None = None + + +def get_discord_client() -> DiscordClient: + global _client + if _client is None: + _client = DiscordClient(token=os.environ.get("DISCORD_TOKEN", "")) + return _client diff --git a/tests/test_discord_reply.py b/tests/test_discord_reply.py new file mode 100644 index 0000000..db5c42b --- /dev/null +++ b/tests/test_discord_reply.py @@ -0,0 +1,48 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + + +def test_discord_reply_success(): + mock_client = MagicMock() + mock_client.send_message = AsyncMock( + return_value={"id": "123456", "content": "test message"} + ) + + with patch( + "agent.tools.discord_reply.get_discord_client", return_value=mock_client + ), patch.dict("os.environ", {"DISCORD_CHANNEL_ID": "999"}): + from agent.tools.discord_reply import discord_reply + result = discord_reply(message="test message") + assert result["success"] is True + mock_client.send_message.assert_called_once() + + +def test_discord_reply_empty_message(): + from agent.tools.discord_reply import discord_reply + result = discord_reply(message="") + assert result["success"] is False + + +def test_discord_reply_no_channel_configured(): + with patch.dict("os.environ", {"DISCORD_CHANNEL_ID": ""}, clear=False): + from agent.tools.discord_reply import discord_reply + result = discord_reply(message="test") + assert result["success"] is False + assert "DISCORD" in result.get("error", "") + + +@pytest.mark.asyncio +async def test_discord_client_send_message(): + import httpx + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.json.return_value = {"id": "msg123", "content": "hello"} + mock_resp.raise_for_status = MagicMock() + + from agent.utils.discord_client import DiscordClient + client = DiscordClient(token="test-token") + client._client.post = AsyncMock(return_value=mock_resp) + + result = await client.send_message(channel_id="999", content="hello") + assert result["id"] == "msg123" + client._client.post.assert_called_once() diff --git a/tests/test_gitea_comment.py b/tests/test_gitea_comment.py new file mode 100644 index 0000000..c90197d --- /dev/null +++ b/tests/test_gitea_comment.py @@ -0,0 +1,48 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock + + +def test_gitea_comment_success(): + mock_client = MagicMock() + mock_client.create_issue_comment = AsyncMock( + return_value={"id": 42, "body": "test comment"} + ) + + with patch( + "agent.tools.gitea_comment.get_gitea_client", return_value=mock_client + ), patch( + "agent.tools.gitea_comment._get_repo_info", + return_value=("quant", "galaxis-po"), + ): + from agent.tools.gitea_comment import gitea_comment + result = gitea_comment(message="test comment", issue_number=1) + assert result["success"] is True + assert result["comment_id"] == 42 + + +def test_gitea_comment_missing_issue_number(): + from agent.tools.gitea_comment import gitea_comment + result = gitea_comment(message="test", issue_number=0) + assert result["success"] is False + assert "issue_number" in result["error"] + + +def test_gitea_comment_api_error(): + import httpx + mock_client = MagicMock() + mock_client.create_issue_comment = AsyncMock( + side_effect=httpx.HTTPStatusError( + "404", request=MagicMock(), response=MagicMock(status_code=404) + ) + ) + + with patch( + "agent.tools.gitea_comment.get_gitea_client", return_value=mock_client + ), patch( + "agent.tools.gitea_comment._get_repo_info", + return_value=("quant", "galaxis-po"), + ): + from agent.tools.gitea_comment import gitea_comment + result = gitea_comment(message="test", issue_number=999) + assert result["success"] is False + assert "error" in result