Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Tests package.
|
||||
@@ -0,0 +1,158 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
from pathlib import Path
|
||||
from main import JSONFormatter
|
||||
from gitea.tools.coding_tools import CodingTools
|
||||
from core.dispatcher import AgentDispatcher
|
||||
from core.queue import WorkItem
|
||||
from gitea.client import GiteaClient
|
||||
from gitea.tools.gitea_tools import GiteaTools
|
||||
from gitea.models import IssueModel, PullRequestModel
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
def test_json_formatter() -> None:
|
||||
formatter = JSONFormatter()
|
||||
record = logging.LogRecord(
|
||||
name="test-logger",
|
||||
level=logging.INFO,
|
||||
pathname="test.py",
|
||||
lineno=10,
|
||||
msg="Test log message",
|
||||
args=(),
|
||||
exc_info=None
|
||||
)
|
||||
formatted = formatter.format(record)
|
||||
data = json.loads(formatted)
|
||||
assert data["level"] == "INFO"
|
||||
assert data["logger"] == "test-logger"
|
||||
assert data["message"] == "Test log message"
|
||||
assert "timestamp" in data
|
||||
|
||||
|
||||
def test_read_file_limit(tmp_path: Path) -> None:
|
||||
# Create a large file
|
||||
large_file = tmp_path / "large_file.txt"
|
||||
lines = [f"Line {i}\n" for i in range(1, 400)]
|
||||
large_file.write_text("".join(lines), encoding="utf-8")
|
||||
|
||||
coding_tools = CodingTools(str(tmp_path))
|
||||
# Test read with default limit (250)
|
||||
result = coding_tools.read_file("large_file.txt", offset=1)
|
||||
result_lines = [line.strip() for line in result.splitlines() if line.strip()]
|
||||
assert len(result_lines) == 250
|
||||
assert result_lines[0] == "1: Line 1"
|
||||
assert result_lines[-1] == "250: Line 250"
|
||||
|
||||
# Test read with custom limit
|
||||
result_custom = coding_tools.read_file("large_file.txt", offset=10, limit=10)
|
||||
result_custom_lines = [line.strip() for line in result_custom.splitlines() if line.strip()]
|
||||
assert len(result_custom_lines) == 10
|
||||
assert result_custom_lines[0] == "10: Line 10"
|
||||
assert result_custom_lines[-1] == "19: Line 19"
|
||||
|
||||
|
||||
def test_parse_verification_commands(tmp_path: Path) -> None:
|
||||
agents_md = tmp_path / "AGENTS.md"
|
||||
agents_md.write_text(
|
||||
"# Agent Instructions\n\n"
|
||||
"## Verification\n"
|
||||
"Please verify your changes with:\n"
|
||||
"```bash\n"
|
||||
"pytest -v\n"
|
||||
"npm run lint\n"
|
||||
"```\n\n"
|
||||
"## Something Else\n"
|
||||
"```bash\n"
|
||||
"ignored command\n"
|
||||
"```\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
coding_tools = CodingTools(str(tmp_path))
|
||||
commands = coding_tools._parse_verification_commands()
|
||||
assert commands == ["pytest -v", "npm run lint"]
|
||||
|
||||
|
||||
def test_run_verification_failure(tmp_path: Path) -> None:
|
||||
agents_md = tmp_path / "AGENTS.md"
|
||||
agents_md.write_text(
|
||||
"## Verification\n"
|
||||
"```bash\n"
|
||||
"false\n"
|
||||
"```\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
coding_tools = CodingTools(str(tmp_path))
|
||||
success, log_msg = coding_tools.run_verification()
|
||||
assert not success
|
||||
assert "false" in log_msg
|
||||
|
||||
|
||||
@patch("core.dispatcher.CodingAgent")
|
||||
@patch("core.dispatcher.PlanningAgent")
|
||||
async def test_dispatch_planning_and_coding_phases(mock_planning_class: MagicMock, mock_coding_class: MagicMock) -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||
|
||||
# Mock no existing PRs
|
||||
mock_client.list_repo_pull_requests.return_value = []
|
||||
mock_client.get_issue_comments.return_value = []
|
||||
|
||||
from gitea.models import UserModel
|
||||
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||
|
||||
mock_pr = PullRequestModel(
|
||||
number=42,
|
||||
title="fix bug",
|
||||
body="bug details",
|
||||
user=UserModel(login="meeks-ai")
|
||||
)
|
||||
mock_client.get_pull_request.return_value = mock_pr
|
||||
mock_client.get_pull_request_diff.return_value = "diff"
|
||||
mock_client.get_pull_request_comments.return_value = []
|
||||
mock_client.get_pull_request_files.return_value = []
|
||||
mock_client.get_pr_reviews.return_value = []
|
||||
|
||||
# Mock agent instances
|
||||
mock_planning_agent = MagicMock()
|
||||
mock_planning_agent.run_with_tools = AsyncMock(return_value="Plan: Modify file A")
|
||||
mock_planning_class.return_value = mock_planning_agent
|
||||
|
||||
mock_coding_agent = MagicMock()
|
||||
mock_coding_agent.run_with_tools = AsyncMock(return_value="PR #1 Created")
|
||||
mock_coding_class.return_value = mock_coding_agent
|
||||
|
||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||
|
||||
work_item = WorkItem(
|
||||
repo_full_name="meeks/repo1",
|
||||
task_type="pr",
|
||||
task_number=42,
|
||||
task_info=mock_pr,
|
||||
priority=0
|
||||
)
|
||||
|
||||
# We mock os.path.isdir to return True so os.chdir won't fail or crash in test
|
||||
with patch("os.path.isdir", return_value=True), patch("os.chdir"):
|
||||
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0] == "PR #1 Created"
|
||||
|
||||
# Verify both agents ran
|
||||
assert mock_planning_agent.run_with_tools.call_count == 1
|
||||
assert mock_coding_agent.run_with_tools.call_count == 1
|
||||
|
||||
# Verify planning prompt was passed correct context
|
||||
planning_call_args = mock_planning_agent.run_with_tools.call_args[0]
|
||||
assert "PHASE 1: PLANNING PHASE" in planning_call_args[0]
|
||||
|
||||
# Verify coding prompt received the generated plan
|
||||
coding_call_args = mock_coding_agent.run_with_tools.call_args[0]
|
||||
assert "PHASE 2: EXECUTION/CODING PHASE" in coding_call_args[0]
|
||||
assert "Plan: Modify file A" in coding_call_args[0]
|
||||
@@ -0,0 +1,124 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
from gitea.client import GiteaClient
|
||||
|
||||
|
||||
def test_gitea_client_list_repo_issues() -> None:
|
||||
client: GiteaClient = GiteaClient()
|
||||
with patch("httpx.Client.get") as mock_get:
|
||||
mock_response: MagicMock = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = []
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
# Test default parameter ("open")
|
||||
client.list_repo_issues("owner", "repo")
|
||||
mock_get.assert_called_once()
|
||||
args, _ = mock_get.call_args
|
||||
assert "type=issues" in args[0]
|
||||
assert "state=open" in args[0]
|
||||
|
||||
mock_get.reset_mock()
|
||||
|
||||
# Test custom parameter ("closed")
|
||||
client.list_repo_issues("owner", "repo", state="closed")
|
||||
mock_get.assert_called_once()
|
||||
args, _ = mock_get.call_args
|
||||
assert "type=issues" in args[0]
|
||||
assert "state=closed" in args[0]
|
||||
|
||||
|
||||
def test_gitea_client_list_repo_pull_requests() -> None:
|
||||
client: GiteaClient = GiteaClient()
|
||||
with patch("httpx.Client.get") as mock_get:
|
||||
mock_response: MagicMock = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = []
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
# Test default parameter ("open")
|
||||
client.list_repo_pull_requests("owner", "repo")
|
||||
mock_get.assert_called_once()
|
||||
args, _ = mock_get.call_args
|
||||
assert "state=open" in args[0]
|
||||
|
||||
mock_get.reset_mock()
|
||||
|
||||
# Test custom parameter ("closed")
|
||||
client.list_repo_pull_requests("owner", "repo", state="closed")
|
||||
mock_get.assert_called_once()
|
||||
args, _ = mock_get.call_args
|
||||
assert "state=closed" in args[0]
|
||||
|
||||
|
||||
def test_gitea_client_list_assigned_issues() -> None:
|
||||
client: GiteaClient = GiteaClient()
|
||||
user_mock: MagicMock = MagicMock()
|
||||
user_mock.login = "testuser"
|
||||
|
||||
with patch.object(client, "get_authenticated_user", return_value=user_mock), \
|
||||
patch("httpx.get") as mock_get:
|
||||
mock_response: MagicMock = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = []
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
client.list_assigned_issues("owner", "repo")
|
||||
mock_get.assert_called_once()
|
||||
args, _ = mock_get.call_args
|
||||
assert "type=issues" in args[0]
|
||||
assert "state=open" in args[0]
|
||||
|
||||
|
||||
def test_gitea_client_list_assigned_pull_requests() -> None:
|
||||
client: GiteaClient = GiteaClient()
|
||||
user_mock: MagicMock = MagicMock()
|
||||
user_mock.login = "testuser"
|
||||
|
||||
with patch.object(client, "get_authenticated_user", return_value=user_mock), \
|
||||
patch("httpx.get") as mock_get:
|
||||
mock_response: MagicMock = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = [
|
||||
{"number": 1, "title": "PR 1", "assignee": {"login": "testuser"}, "user": {"login": "otheruser"}},
|
||||
{"number": 2, "title": "PR 2", "assignee": None, "user": {"login": "testuser"}},
|
||||
{"number": 3, "title": "PR 3", "assignee": {"login": "otheruser"}, "user": {"login": "otheruser"}}
|
||||
]
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
res = client.list_assigned_pull_requests("owner", "repo")
|
||||
mock_get.assert_called_once()
|
||||
assert len(res) == 2
|
||||
numbers = [pr.number for pr in res]
|
||||
assert 1 in numbers
|
||||
assert 2 in numbers
|
||||
assert 3 not in numbers
|
||||
|
||||
|
||||
def test_gitea_client_list_unread_notifications() -> None:
|
||||
client: GiteaClient = GiteaClient()
|
||||
with patch("httpx.Client.get") as mock_get:
|
||||
mock_response: MagicMock = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = [
|
||||
{"id": 1, "repository": {"owner": {"login": "meeks"}}},
|
||||
{"id": 2, "repository": {"owner": {"login": "other"}}},
|
||||
]
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
# Test without since
|
||||
res = client.list_unread_notifications()
|
||||
mock_get.assert_called_once()
|
||||
_, kwargs = mock_get.call_args
|
||||
assert kwargs.get("params") == {"all": "false"}
|
||||
assert len(res) == 1
|
||||
assert res[0]["id"] == 1
|
||||
|
||||
mock_get.reset_mock()
|
||||
|
||||
# Test with since
|
||||
res = client.list_unread_notifications(since="2026-06-30T21:41:16+02:00")
|
||||
mock_get.assert_called_once()
|
||||
_, kwargs = mock_get.call_args
|
||||
assert kwargs.get("params") == {"all": "false", "since": "2026-06-30T21:41:16+02:00"}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from gitea.tools.coding_tools import CodingTools
|
||||
|
||||
|
||||
def test_list_files(tmp_path: Path) -> None:
|
||||
d: Path = tmp_path / "sub"
|
||||
d.mkdir()
|
||||
f: Path = d / "hello.txt"
|
||||
f.write_text("content")
|
||||
|
||||
res: str = CodingTools().list_files(str(d))
|
||||
assert "hello.txt" in res
|
||||
|
||||
|
||||
def test_list_files_error() -> None:
|
||||
res: str = CodingTools().list_files("/nonexistent/directory/path/here")
|
||||
assert "Error listing files" in res
|
||||
|
||||
|
||||
def test_read_file(tmp_path: Path) -> None:
|
||||
f: Path = tmp_path / "test.txt"
|
||||
f.write_text("line1\nline2\nline3\n")
|
||||
|
||||
res: str = CodingTools().read_file(str(f), offset=1, limit=2)
|
||||
assert "1: line1" in res
|
||||
assert "2: line2" in res
|
||||
assert "3: line3" not in res
|
||||
|
||||
|
||||
def test_read_file_empty_or_out_of_bounds(tmp_path: Path) -> None:
|
||||
f: Path = tmp_path / "test.txt"
|
||||
f.write_text("")
|
||||
res: str = CodingTools().read_file(str(f), offset=10, limit=2)
|
||||
assert res == "File is empty or offset out of bounds."
|
||||
|
||||
|
||||
def test_read_file_error() -> None:
|
||||
res: str = CodingTools().read_file("/nonexistent/file/path/here")
|
||||
assert "Error reading file" in res
|
||||
|
||||
|
||||
def test_write_file(tmp_path: Path) -> None:
|
||||
f: Path = tmp_path / "new_dir" / "test.txt"
|
||||
res: str = CodingTools().write_file(str(f), "content")
|
||||
assert "written successfully" in res
|
||||
assert f.read_text() == "content"
|
||||
|
||||
|
||||
def test_write_file_error() -> None:
|
||||
res: str = CodingTools().write_file("", "content")
|
||||
assert "Error writing file" in res
|
||||
|
||||
|
||||
def test_edit_file(tmp_path: Path) -> None:
|
||||
f: Path = tmp_path / "test.txt"
|
||||
f.write_text("hello world")
|
||||
res: str = CodingTools().edit_file(str(f), "world", "there")
|
||||
assert "edited successfully" in res
|
||||
assert f.read_text() == "hello there"
|
||||
|
||||
|
||||
def test_edit_file_not_found(tmp_path: Path) -> None:
|
||||
f: Path = tmp_path / "test.txt"
|
||||
f.write_text("hello world")
|
||||
res: str = CodingTools().edit_file(str(f), "nonexistent", "there")
|
||||
assert "not found" in res
|
||||
|
||||
|
||||
def test_edit_file_error() -> None:
|
||||
res: str = CodingTools().edit_file("/nonexistent/file/path/here", "world", "there")
|
||||
assert "Error editing file" in res
|
||||
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_run_command_success(mock_popen: MagicMock) -> None:
|
||||
mock_process: MagicMock = MagicMock()
|
||||
mock_process.communicate.return_value = ("output_stdout", "output_stderr")
|
||||
mock_process.returncode = 0
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
res: str = CodingTools().run_command("echo hello")
|
||||
assert "output_stdout" in res
|
||||
assert "output_stderr" in res
|
||||
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_run_command_failure(mock_popen: MagicMock) -> None:
|
||||
mock_process: MagicMock = MagicMock()
|
||||
mock_process.communicate.return_value = ("output_stdout", "output_stderr")
|
||||
mock_process.returncode = 1
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
res: str = CodingTools().run_command("false")
|
||||
assert "Command failed with exit code 1" in res
|
||||
|
||||
|
||||
def test_run_command_error() -> None:
|
||||
with patch("subprocess.Popen", side_effect=ValueError("Invalid run")):
|
||||
res: str = CodingTools().run_command("echo")
|
||||
assert "Error running command" in res
|
||||
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_run_command_timeout(mock_popen: MagicMock) -> None:
|
||||
mock_process: MagicMock = MagicMock()
|
||||
mock_process.communicate.side_effect = [
|
||||
subprocess.TimeoutExpired(cmd="test", timeout=1),
|
||||
("stdout_after_kill", "stderr_after_kill")
|
||||
]
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
res: str = CodingTools().run_command("hang_cmd", timeout=1)
|
||||
assert "Command timed out after 1 seconds" in res
|
||||
assert "stdout_after_kill" in res
|
||||
mock_process.kill.assert_called_once()
|
||||
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_grep_search_success(mock_popen: MagicMock) -> None:
|
||||
mock_process: MagicMock = MagicMock()
|
||||
mock_process.communicate.return_value = ("match_line", "")
|
||||
mock_process.returncode = 0
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
res: str = CodingTools().grep_search("pattern", "/path")
|
||||
assert res == "match_line"
|
||||
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_grep_search_no_matches(mock_popen: MagicMock) -> None:
|
||||
mock_process: MagicMock = MagicMock()
|
||||
mock_process.communicate.return_value = ("", "")
|
||||
mock_process.returncode = 1
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
res: str = CodingTools().grep_search("pattern", "/path")
|
||||
assert "No matches found" in res
|
||||
|
||||
|
||||
def test_grep_search_error() -> None:
|
||||
with patch("subprocess.Popen", side_effect=ValueError("Invalid run")):
|
||||
res: str = CodingTools().grep_search("pattern")
|
||||
assert "Error during grep search" in res
|
||||
@@ -0,0 +1,544 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch, ANY
|
||||
from core.dispatcher import AgentDispatcher
|
||||
from core.queue import WorkItem
|
||||
from gitea.client import GiteaClient
|
||||
from gitea.tools.gitea_tools import GiteaTools
|
||||
from gitea.models import PullRequestModel, IssueModel, CommentModel, UserModel
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
async def test_dispatch_skips_issue_with_existing_pr() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||
|
||||
# Mock list_repo_pull_requests to return a PR that closes issue #42
|
||||
pr = PullRequestModel(
|
||||
number=101,
|
||||
title="fix: resolve bug",
|
||||
body="closes #42"
|
||||
)
|
||||
mock_client.list_repo_pull_requests.return_value = [pr]
|
||||
|
||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||
|
||||
work_item = WorkItem(
|
||||
repo_full_name="meeks/repo1",
|
||||
task_type="issue",
|
||||
task_number=42,
|
||||
task_info=IssueModel(number=42),
|
||||
priority=0
|
||||
)
|
||||
|
||||
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||
|
||||
assert len(results) == 1
|
||||
assert "SKIP: A pull request (PR #101) addressing issue #42 already exists" in results[0]
|
||||
mock_client.list_repo_pull_requests.assert_called_once_with("meeks", "repo1")
|
||||
|
||||
|
||||
@patch("core.dispatcher.CoordinatorAgent")
|
||||
async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock) -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||
|
||||
# Mock list_repo_pull_requests to return PRs that don't address issue #42
|
||||
pr = PullRequestModel(
|
||||
number=101,
|
||||
title="feat: add something",
|
||||
body="closes #99"
|
||||
)
|
||||
mock_client.list_repo_pull_requests.return_value = [pr]
|
||||
mock_client.get_issue_comments.return_value = []
|
||||
|
||||
# Mock CoordinatorAgent invoking propose_plan tool
|
||||
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||
coord_tools.propose_plan(plan="- change X", issue_number=42)
|
||||
return "Agent proposed plan."
|
||||
|
||||
mock_coord_instance = MagicMock()
|
||||
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
||||
mock_coord_class.return_value = mock_coord_instance
|
||||
|
||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||
|
||||
work_item = WorkItem(
|
||||
repo_full_name="meeks/repo1",
|
||||
task_type="issue",
|
||||
task_number=42,
|
||||
task_info=IssueModel(number=42, title="test", body="test desc"),
|
||||
priority=0
|
||||
)
|
||||
|
||||
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||
|
||||
assert len(results) == 1
|
||||
assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0]
|
||||
|
||||
|
||||
@patch("core.dispatcher.CodingAgent")
|
||||
async def test_build_pr_mission_injects_issue_context(mock_agent_class: MagicMock) -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||
|
||||
# Mock get_pull_request, get_pull_request_diff, etc.
|
||||
pr = PullRequestModel(
|
||||
number=101,
|
||||
title="fix: resolve bug",
|
||||
body="closes #42",
|
||||
head={"ref": "branch1"},
|
||||
base={"ref": "master"}
|
||||
)
|
||||
mock_client.get_pull_request.return_value = pr
|
||||
mock_client.get_pull_request_diff.return_value = "diff context"
|
||||
mock_client.get_pull_request_files.return_value = []
|
||||
mock_client.get_pull_request_comments.return_value = []
|
||||
|
||||
# Mock the connected issue and its comments
|
||||
issue = IssueModel(number=42, title="bug description")
|
||||
mock_client.get_issue.return_value = issue
|
||||
|
||||
comment = CommentModel(id=1, body="First comment")
|
||||
mock_client.get_issue_comments.return_value = [comment]
|
||||
|
||||
# Mock CodingAgent
|
||||
mock_agent_instance = MagicMock()
|
||||
mock_agent_instance.run_with_tools = AsyncMock(return_value="PR Reviewed.")
|
||||
mock_agent_class.return_value = mock_agent_instance
|
||||
|
||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||
|
||||
work_item = WorkItem(
|
||||
repo_full_name="meeks/repo1",
|
||||
task_type="pr",
|
||||
task_number=101,
|
||||
task_info=PullRequestModel(number=101, title="fix: resolve bug", body="closes #42"),
|
||||
priority=0
|
||||
)
|
||||
|
||||
# We will patch the dispatcher._build_pr_mission output validation
|
||||
mission = dispatcher._build_pr_mission(work_item)
|
||||
|
||||
assert "CONNECTED ISSUE CONTEXT" in mission
|
||||
assert "Connected Issue #42" in mission
|
||||
assert "bug description" in mission
|
||||
assert "First comment" in mission
|
||||
|
||||
mock_client.get_issue.assert_called_once_with("meeks", "repo1", 42)
|
||||
mock_client.get_issue_comments.assert_called_once_with("meeks", "repo1", 42)
|
||||
|
||||
|
||||
async def test_find_pr_for_issue_by_branch() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||
|
||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||
|
||||
# 1. Matches fix/issue-42-some-desc
|
||||
pr1 = PullRequestModel(number=102, head={"ref": "fix/issue-42-some-desc"})
|
||||
mock_client.list_repo_pull_requests.return_value = [pr1]
|
||||
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
|
||||
|
||||
# 2. Matches fix/42
|
||||
pr2 = PullRequestModel(number=102, head={"ref": "fix/42"})
|
||||
mock_client.list_repo_pull_requests.return_value = [pr2]
|
||||
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
|
||||
|
||||
# 3. Matches fix-42_desc
|
||||
pr3 = PullRequestModel(number=102, head={"ref": "fix-42_desc"})
|
||||
mock_client.list_repo_pull_requests.return_value = [pr3]
|
||||
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
|
||||
|
||||
# 4. Does NOT match fix/142
|
||||
pr4 = PullRequestModel(number=102, head={"ref": "fix/142"})
|
||||
mock_client.list_repo_pull_requests.return_value = [pr4]
|
||||
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is None
|
||||
|
||||
# 5. Does NOT match fix/421
|
||||
pr5 = PullRequestModel(number=102, head={"ref": "fix/421"})
|
||||
mock_client.list_repo_pull_requests.return_value = [pr5]
|
||||
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is None
|
||||
|
||||
|
||||
|
||||
async def test_find_pr_for_issue_by_raw_mention() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||
|
||||
# PR body mentions #42
|
||||
pr = PullRequestModel(
|
||||
number=103,
|
||||
title="some fix",
|
||||
body="This is for #42 to fix the bug",
|
||||
head={"ref": "some-branch"}
|
||||
)
|
||||
mock_client.list_repo_pull_requests.return_value = [pr]
|
||||
|
||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||
res = dispatcher._find_pr_for_issue("meeks/repo1", 42)
|
||||
assert res is not None
|
||||
assert res.number == 103
|
||||
|
||||
|
||||
async def test_dispatch_skips_already_reviewed_pr() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||
|
||||
from gitea.models import UserModel
|
||||
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||
pr = PullRequestModel(
|
||||
number=104,
|
||||
title="already reviewed PR",
|
||||
body="closes #42",
|
||||
user=UserModel(login="meeks-ai")
|
||||
)
|
||||
mock_client.get_pull_request.return_value = pr
|
||||
mock_client.get_pull_request_diff.return_value = "diff"
|
||||
mock_client.get_pull_request_comments.return_value = [
|
||||
CommentModel(id=1, body="Reviewed by AI Agent: Looks good.")
|
||||
]
|
||||
|
||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||
|
||||
work_item = WorkItem(
|
||||
repo_full_name="meeks/repo1",
|
||||
task_type="pr",
|
||||
task_number=104,
|
||||
task_info=PullRequestModel(number=104, title="already reviewed PR", body="closes #42"),
|
||||
priority=0
|
||||
)
|
||||
|
||||
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||
assert len(results) == 1
|
||||
assert "SKIP: PR #104 has already been addressed by AI" in results[0]
|
||||
|
||||
|
||||
# ── _is_awaiting_reply tests ─────────────────────────────────────────────────
|
||||
|
||||
def _make_comment(login: str, body: str) -> CommentModel:
|
||||
from gitea.models import UserModel
|
||||
user = UserModel(login=login)
|
||||
return CommentModel(id=1, body=body, user=user)
|
||||
|
||||
|
||||
def test_is_awaiting_reply_no_comments() -> None:
|
||||
dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock())
|
||||
assert dispatcher._is_awaiting_reply([]) is False
|
||||
|
||||
|
||||
def test_is_awaiting_reply_no_question() -> None:
|
||||
dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock())
|
||||
comments = [_make_comment("meeks-ai", "I will fix this now.")]
|
||||
assert dispatcher._is_awaiting_reply(comments) is False
|
||||
|
||||
|
||||
def test_is_awaiting_reply_agent_question_no_human_reply() -> None:
|
||||
dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock())
|
||||
body = "Should I use approach A or B?\n<!-- agent:awaiting-reply -->"
|
||||
comments = [_make_comment("meeks-ai", body)]
|
||||
assert dispatcher._is_awaiting_reply(comments) is True
|
||||
|
||||
|
||||
def test_is_awaiting_reply_agent_question_human_replied() -> None:
|
||||
dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock())
|
||||
body = "Should I use approach A or B?\n<!-- agent:awaiting-reply -->"
|
||||
comments = [
|
||||
_make_comment("meeks-ai", body),
|
||||
_make_comment("michael", "Use approach A please."),
|
||||
]
|
||||
assert dispatcher._is_awaiting_reply(comments) is False
|
||||
|
||||
|
||||
def test_is_awaiting_reply_no_marker_not_detected() -> None:
|
||||
"""Agent asked a question but forgot the marker — should NOT be skipped."""
|
||||
dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock())
|
||||
comments = [_make_comment("meeks-ai", "Should I use approach A or B?")]
|
||||
assert dispatcher._is_awaiting_reply(comments) is False
|
||||
|
||||
|
||||
@patch("core.dispatcher.CoordinatorAgent")
|
||||
async def test_dispatch_proposes_plan(mock_coord_class: MagicMock) -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||
|
||||
mock_client.list_repo_pull_requests.return_value = []
|
||||
mock_client.get_issue_comments.return_value = []
|
||||
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||
|
||||
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||
coord_tools.propose_plan(plan="- Add endpoint\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->", issue_number=42)
|
||||
return "Agent proposed plan."
|
||||
mock_coord_instance = MagicMock()
|
||||
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
||||
mock_coord_class.return_value = mock_coord_instance
|
||||
|
||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||
work_item = WorkItem(
|
||||
repo_full_name="meeks/repo1",
|
||||
task_type="issue",
|
||||
task_number=42,
|
||||
task_info=IssueModel(number=42, title="add X", body=""),
|
||||
priority=0
|
||||
)
|
||||
|
||||
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||
assert len(results) == 1
|
||||
assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0]
|
||||
mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "### Proposed Implementation Plan\n\n- Add endpoint\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->\n\nIs this plan ok for implementation or do you have any comments/changes?\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->")
|
||||
|
||||
|
||||
@patch("core.dispatcher.CoordinatorAgent")
|
||||
async def test_dispatch_answers_question(mock_coord_class: MagicMock) -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||
|
||||
mock_client.list_repo_pull_requests.return_value = []
|
||||
mock_client.get_issue_comments.return_value = []
|
||||
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||
|
||||
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||
coord_tools.answer_question(answer="X works by doing Y.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->", issue_number=42)
|
||||
return "Agent answered question."
|
||||
mock_coord_instance = MagicMock()
|
||||
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
||||
mock_coord_class.return_value = mock_coord_instance
|
||||
|
||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||
work_item = WorkItem(
|
||||
repo_full_name="meeks/repo1",
|
||||
task_type="issue",
|
||||
task_number=42,
|
||||
task_info=IssueModel(number=42, title="how does X work", body=""),
|
||||
priority=0
|
||||
)
|
||||
|
||||
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||
assert len(results) == 1
|
||||
assert "POSTED_COMMENT: ANSWER_QUESTION" in results[0]
|
||||
mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "X works by doing Y.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->\n\nIs this answer satisfactory?\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->")
|
||||
|
||||
|
||||
@patch("core.dispatcher.CoordinatorAgent")
|
||||
async def test_dispatch_closes_issue_on_satisfaction(mock_coord_class: MagicMock) -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||
|
||||
mock_client.list_repo_pull_requests.return_value = []
|
||||
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||
|
||||
# Human comments indicating satisfaction after our answer
|
||||
mock_client.get_issue_comments.return_value = [
|
||||
_make_comment("meeks-ai", "Here is the answer.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->"),
|
||||
_make_comment("michael", "Yes, thanks! That makes sense.")
|
||||
]
|
||||
|
||||
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||
coord_tools.close_issue(comment="Closing the issue now. Let me know if you need anything else!", issue_number=42)
|
||||
return "Agent closed issue."
|
||||
mock_coord_instance = MagicMock()
|
||||
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
||||
mock_coord_class.return_value = mock_coord_instance
|
||||
|
||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||
work_item = WorkItem(
|
||||
repo_full_name="meeks/repo1",
|
||||
task_type="issue",
|
||||
task_number=42,
|
||||
task_info=IssueModel(number=42, title="how does X work", body=""),
|
||||
priority=0
|
||||
)
|
||||
|
||||
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||
assert len(results) == 1
|
||||
assert "CLOSED_ISSUE: Issue #42 closed." in results[0]
|
||||
mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "Closing the issue now. Let me know if you need anything else!")
|
||||
mock_client.close_issue.assert_called_once_with("meeks", "repo1", 42)
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
@patch("core.dispatcher.CodingAgent")
|
||||
@patch("core.dispatcher.CoordinatorAgent")
|
||||
async def test_dispatch_executes_approved_plan_and_creates_wip_pr(mock_coord_class: MagicMock, mock_coding_class: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||
|
||||
mock_client.list_repo_pull_requests.return_value = []
|
||||
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||
mock_client.get_issue_comments.return_value = [
|
||||
_make_comment("meeks-ai", "### Proposed Plan\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
|
||||
_make_comment("michael", "looks good, go ahead")
|
||||
]
|
||||
|
||||
# Return PR object on creation
|
||||
mock_pr = PullRequestModel(number=105, title="WIP: add X", html_url="http://gitea/pr/105", head={"ref": "fix/issue-42-add-x"})
|
||||
mock_client.create_pull_request.return_value = mock_pr
|
||||
|
||||
# Mock planning agent deciding EXECUTE_PLAN
|
||||
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||
coord_tools.start_implementation(approved_plan="Step 1. Code X", issue_number=42)
|
||||
return "Agent decided execute plan."
|
||||
mock_coord_class.return_value.decide_action = AsyncMock(side_effect=mock_decide)
|
||||
|
||||
# Mock coding agent executing plan
|
||||
mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Completed Successfully.")
|
||||
|
||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||
work_item = WorkItem(
|
||||
repo_full_name="meeks/repo1",
|
||||
task_type="issue",
|
||||
task_number=42,
|
||||
task_info=IssueModel(number=42, title="add X", body=""),
|
||||
priority=0
|
||||
)
|
||||
|
||||
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||
assert len(results) == 1
|
||||
assert results[0] == "PR Completed Successfully."
|
||||
|
||||
# Verify subprocess git commands
|
||||
mock_run.assert_any_call(["git", "checkout", "master"], cwd=ANY, check=True)
|
||||
mock_run.assert_any_call(["git", "checkout", "-b", "fix/issue-42-add-x"], cwd=ANY, check=True)
|
||||
|
||||
# Verify WIP PR creation and starting comment
|
||||
mock_client.create_pull_request.assert_called_once_with(
|
||||
"meeks", "repo1", head="fix/issue-42-add-x", base="master", title="WIP: add X", description="Work in progress for issue #42."
|
||||
)
|
||||
mock_client.add_comment.assert_any_call("meeks", "repo1", 42, "Started work on PR #105 (http://gitea/pr/105).")
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
@patch("core.dispatcher.CodingAgent")
|
||||
@patch("core.dispatcher.CoordinatorAgent")
|
||||
async def test_dispatch_resumes_wip_pr(mock_coord_class: MagicMock, mock_coding_class: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||
|
||||
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||
|
||||
# Existing WIP PR addressing issue #42
|
||||
wip_pr = PullRequestModel(
|
||||
number=105,
|
||||
title="WIP: add X",
|
||||
state="open",
|
||||
head={"ref": "fix/issue-42-add-x"}
|
||||
)
|
||||
mock_client.list_repo_pull_requests.return_value = [wip_pr]
|
||||
mock_client.get_pr_reviews.return_value = []
|
||||
|
||||
mock_client.get_issue_comments.return_value = [
|
||||
_make_comment("meeks-ai", "### Proposed Plan\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
|
||||
_make_comment("michael", "looks good, go ahead")
|
||||
]
|
||||
mock_client.get_pull_request_comments.return_value = []
|
||||
|
||||
# Mock planning agent deciding EXECUTE_PLAN
|
||||
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||
coord_tools.start_implementation(approved_plan="Step 1. Resume coding", issue_number=42)
|
||||
return "Agent decided execute plan."
|
||||
mock_coord_class.return_value.decide_action = AsyncMock(side_effect=mock_decide)
|
||||
|
||||
# Mock coding agent executing plan
|
||||
mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Updated Successfully.")
|
||||
|
||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||
work_item = WorkItem(
|
||||
repo_full_name="meeks/repo1",
|
||||
task_type="issue",
|
||||
task_number=42,
|
||||
task_info=IssueModel(number=42, title="add X", body=""),
|
||||
priority=0
|
||||
)
|
||||
|
||||
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||
assert len(results) == 1
|
||||
assert results[0] == "PR Updated Successfully."
|
||||
|
||||
# Ensure create_pull_request was NOT called since it already exists
|
||||
mock_client.create_pull_request.assert_not_called()
|
||||
|
||||
|
||||
async def test_dispatch_skips_pr_if_not_requested_reviewer() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||
|
||||
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||
|
||||
# PR authored by michael, requested reviewers is empty (agent not requested)
|
||||
pr_detail = PullRequestModel(
|
||||
number=201,
|
||||
title="some feature",
|
||||
state="open",
|
||||
user=UserModel(login="michael"),
|
||||
requested_reviewers=[]
|
||||
)
|
||||
mock_client.get_pull_request.return_value = pr_detail
|
||||
|
||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||
work_item = WorkItem(
|
||||
repo_full_name="meeks/repo1",
|
||||
task_type="pr",
|
||||
task_number=201,
|
||||
task_info=PullRequestModel(number=201, title="some feature"),
|
||||
priority=0
|
||||
)
|
||||
|
||||
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||
assert len(results) == 1
|
||||
assert "SKIP: Agent is not a requested reviewer" in results[0]
|
||||
|
||||
|
||||
def test_coordinator_tools_registration() -> None:
|
||||
from core.coordinator_tools import CoordinatorTools
|
||||
tools: CoordinatorTools = CoordinatorTools()
|
||||
assert not tools.tool_called
|
||||
assert tools.action == "NO_ACTION"
|
||||
|
||||
tools.propose_plan(plan="my plan", issue_number=42)
|
||||
assert tools.tool_called
|
||||
assert tools.action == "PROPOSE_PLAN"
|
||||
assert tools.arguments == {"plan": "my plan", "issue_number": 42}
|
||||
|
||||
tools.start_implementation(approved_plan="my approved plan", issue_number=42)
|
||||
assert tools.action == "EXECUTE_PLAN"
|
||||
assert tools.arguments == {"approved_plan": "my approved plan", "issue_number": 42}
|
||||
|
||||
|
||||
@patch("core.dispatcher.CoordinatorAgent")
|
||||
async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMock) -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||
|
||||
mock_client.list_repo_pull_requests.return_value = []
|
||||
mock_client.get_issue_comments.return_value = []
|
||||
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||
|
||||
# Mock agent invoking propose_plan tool
|
||||
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||
coord_tools.propose_plan(plan="Step 1. Code X", issue_number=42)
|
||||
return "Agent finished turn after tool calling."
|
||||
|
||||
mock_coord_instance = MagicMock()
|
||||
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
||||
mock_coord_class.return_value = mock_coord_instance
|
||||
|
||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||
work_item = WorkItem(
|
||||
repo_full_name="meeks/repo1",
|
||||
task_type="issue",
|
||||
task_number=42,
|
||||
task_info=IssueModel(number=42, title="add X", body=""),
|
||||
priority=0
|
||||
)
|
||||
|
||||
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||
assert len(results) == 1
|
||||
assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0]
|
||||
mock_client.add_comment.assert_called_once_with(
|
||||
"meeks",
|
||||
"repo1",
|
||||
42,
|
||||
"### Proposed Implementation Plan\n\nStep 1. Code X\n\nIs this plan ok for implementation or do you have any comments/changes?\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
from unittest.mock import MagicMock
|
||||
from gitea.client import GiteaClient
|
||||
from gitea.tools.file_tools import FileTools
|
||||
|
||||
|
||||
def test_get_file_content_string_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.get_file_content.return_value = "file content here"
|
||||
|
||||
file_tools: FileTools = FileTools(mock_client)
|
||||
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
||||
assert res == "1: file content here"
|
||||
mock_client.get_file_content.assert_called_once_with("owner", "repo", "path/to/file")
|
||||
|
||||
|
||||
def test_get_file_content_list_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.get_file_content.return_value = ["line1", "line2"]
|
||||
|
||||
file_tools: FileTools = FileTools(mock_client)
|
||||
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
||||
assert res == "1: line1\n2: line2"
|
||||
|
||||
|
||||
def test_get_file_content_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.get_file_content.side_effect = Exception("API Error")
|
||||
|
||||
file_tools: FileTools = FileTools(mock_client)
|
||||
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
||||
assert "Error getting file content: API Error" in res
|
||||
|
||||
|
||||
def test_get_file_content_with_ref_string_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.get_file_content.return_value = "file content here"
|
||||
|
||||
file_tools: FileTools = FileTools(mock_client)
|
||||
res: str = file_tools.get_file_content_with_ref("owner", "repo", "path/to/file", "main")
|
||||
assert res == "1: file content here"
|
||||
mock_client.get_file_content.assert_called_once_with("owner", "repo", "path/to/file", "main")
|
||||
|
||||
|
||||
def test_get_file_content_with_ref_list_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.get_file_content.return_value = ["line1", "line2"]
|
||||
|
||||
file_tools: FileTools = FileTools(mock_client)
|
||||
res: str = file_tools.get_file_content_with_ref("owner", "repo", "path/to/file", "main")
|
||||
assert res == "1: line1\n2: line2"
|
||||
|
||||
|
||||
def test_get_file_content_with_ref_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.get_file_content.side_effect = Exception("API Error")
|
||||
|
||||
file_tools: FileTools = FileTools(mock_client)
|
||||
res: str = file_tools.get_file_content_with_ref("owner", "repo", "path/to/file", "main")
|
||||
assert "Error getting file content: API Error" in res
|
||||
|
||||
|
||||
def test_commit_file_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.update_file.return_value = {}
|
||||
|
||||
file_tools: FileTools = FileTools(mock_client)
|
||||
res: str = file_tools.commit_file("owner", "repo", "path/to/file", "msg", "content", "branch")
|
||||
assert "committed successfully" in res
|
||||
mock_client.update_file.assert_called_once_with("owner", "repo", "path/to/file", "msg", "content", "branch")
|
||||
|
||||
|
||||
def test_commit_file_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.update_file.side_effect = Exception("API Error")
|
||||
|
||||
file_tools: FileTools = FileTools(mock_client)
|
||||
res: str = file_tools.commit_file("owner", "repo", "path/to/file", "msg", "content", "branch")
|
||||
assert "Error committing file: API Error" in res
|
||||
|
||||
|
||||
def test_update_file_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.update_file.return_value = {}
|
||||
|
||||
file_tools: FileTools = FileTools(mock_client)
|
||||
res: str = file_tools.update_file("owner", "repo", "path/to/file", "msg", "content", "branch")
|
||||
assert "updated in" in res
|
||||
mock_client.update_file.assert_called_once_with("owner", "repo", "path/to/file", "msg", "content", "branch")
|
||||
|
||||
|
||||
def test_update_file_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.update_file.side_effect = Exception("API Error")
|
||||
|
||||
file_tools: FileTools = FileTools(mock_client)
|
||||
res: str = file_tools.update_file("owner", "repo", "path/to/file", "msg", "content", "branch")
|
||||
assert "Error updating file: API Error" in res
|
||||
@@ -0,0 +1,22 @@
|
||||
from unittest.mock import MagicMock
|
||||
from gitea.client import GiteaClient
|
||||
from gitea.tools.git_tools import GitTools
|
||||
|
||||
|
||||
def test_create_branch_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.create_ref.return_value = {}
|
||||
|
||||
git_tools: GitTools = GitTools(mock_client)
|
||||
res: str = git_tools.create_branch("owner", "repo", "ref", "sha")
|
||||
assert res == "Branch 'ref' created successfully in owner/repo."
|
||||
mock_client.create_ref.assert_called_once_with("owner", "repo", "ref", "sha")
|
||||
|
||||
|
||||
def test_create_branch_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.create_ref.side_effect = Exception("API Error")
|
||||
|
||||
git_tools: GitTools = GitTools(mock_client)
|
||||
res: str = git_tools.create_branch("owner", "repo", "ref", "sha")
|
||||
assert res == "Error creating branch: API Error"
|
||||
@@ -0,0 +1,113 @@
|
||||
from unittest.mock import MagicMock
|
||||
from gitea.client import GiteaClient
|
||||
from gitea.tools.gitea_tools import GiteaTools
|
||||
|
||||
|
||||
def test_gitea_tools_delegation() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
gitea_tools: GiteaTools = GiteaTools(mock_client)
|
||||
|
||||
# 1. get_issue
|
||||
gitea_tools.get_issue("owner", "repo", 1)
|
||||
mock_client.get_issue.assert_called_once_with("owner", "repo", 1)
|
||||
|
||||
# 2. get_pull_request
|
||||
gitea_tools.get_pull_request("owner", "repo", 2)
|
||||
mock_client.get_pull_request.assert_called_once_with("owner", "repo", 2)
|
||||
|
||||
# 3. close_issue
|
||||
gitea_tools.close_issue("owner", "repo", 3)
|
||||
mock_client.close_issue.assert_called_once_with("owner", "repo", 3)
|
||||
|
||||
# 4. close_pull_request
|
||||
gitea_tools.close_pull_request("owner", "repo", 4)
|
||||
mock_client.close_pull_request.assert_called_once_with("owner", "repo", 4)
|
||||
|
||||
# 5. get_issue_comments
|
||||
gitea_tools.get_issue_comments("owner", "repo", 5)
|
||||
mock_client.get_issue_comments.assert_called_once_with("owner", "repo", 5)
|
||||
|
||||
# 6. get_pull_request_comments
|
||||
gitea_tools.get_pull_request_comments("owner", "repo", 6)
|
||||
mock_client.get_pull_request_comments.assert_called_once_with("owner", "repo", 6)
|
||||
|
||||
# 7. list_assigned_issues
|
||||
mock_client.list_all_user_repos.return_value = []
|
||||
gitea_tools.list_assigned_issues()
|
||||
mock_client.list_all_user_repos.assert_called()
|
||||
|
||||
# 8. list_assigned_pull_requests
|
||||
gitea_tools.list_assigned_pull_requests()
|
||||
mock_client.list_all_user_repos.assert_called()
|
||||
|
||||
# 9. list_issues
|
||||
gitea_tools.list_issues("owner", "repo")
|
||||
mock_client.list_repo_issues.assert_called_once_with("owner", "repo", "open")
|
||||
|
||||
# 10. list_pull_requests
|
||||
gitea_tools.list_pull_requests("owner", "repo")
|
||||
mock_client.list_repo_pull_requests.assert_called_once_with("owner", "repo", "open")
|
||||
|
||||
# 11. get_file_content
|
||||
gitea_tools.get_file_content("owner", "repo", "path")
|
||||
mock_client.get_file_content.assert_called_with("owner", "repo", "path")
|
||||
|
||||
# 12. create_pull_request
|
||||
gitea_tools.create_pull_request("owner", "repo", "head", "base", "title", "desc")
|
||||
mock_client.create_pr_via_tea.assert_called_once_with("owner", "repo", "title", "desc", "head", "base")
|
||||
|
||||
# 13. create_issue
|
||||
gitea_tools.create_issue("owner", "repo", "title", "body")
|
||||
mock_client.create_issue.assert_called_once_with("owner", "repo", "title", "body", None, None)
|
||||
|
||||
# 14. create_branch
|
||||
gitea_tools.create_branch("owner", "repo", "branch", "sha")
|
||||
mock_client.create_ref.assert_called_once_with("owner", "repo", "branch", "sha")
|
||||
|
||||
# 15. commit_file
|
||||
gitea_tools.commit_file("owner", "repo", "path", "msg", "content", "branch")
|
||||
mock_client.update_file.assert_called_with("owner", "repo", "path", "msg", "content", "branch")
|
||||
|
||||
# 16. add_label_to_issue
|
||||
gitea_tools.add_label_to_issue("owner", "repo", 1, "bug")
|
||||
mock_client.add_label.assert_called_with("owner", "repo", 1, "bug")
|
||||
|
||||
# 17. add_label_to_pr
|
||||
gitea_tools.add_label_to_pr("owner", "repo", 1, "bug")
|
||||
mock_client.add_label_pr.assert_called_once_with("owner", "repo", 1, "bug")
|
||||
|
||||
# 18. add_comment_to_issue
|
||||
gitea_tools.add_comment_to_issue("owner", "repo", 1, "body")
|
||||
mock_client.add_comment.assert_called_with("owner", "repo", 1, "body")
|
||||
|
||||
# 19. get_pull_request_diff
|
||||
gitea_tools.get_pull_request_diff("owner", "repo", 1)
|
||||
mock_client.get_pull_request_diff.assert_called_once_with("owner", "repo", 1)
|
||||
|
||||
# 20. get_pull_request_patch
|
||||
gitea_tools.get_pull_request_patch("owner", "repo", 1)
|
||||
mock_client.get_pull_request_patch.assert_called_once_with("owner", "repo", 1)
|
||||
|
||||
# 21. approve_pull_request
|
||||
gitea_tools.approve_pull_request("owner", "repo", 1, "good")
|
||||
mock_client.approve_pr.assert_called_once_with("owner", "repo", 1, "good")
|
||||
|
||||
# 22. request_changes
|
||||
gitea_tools.request_changes("owner", "repo", 1, "bad")
|
||||
mock_client.request_changes_pr.assert_called_once_with("owner", "repo", 1, "bad")
|
||||
|
||||
# 23. add_comment
|
||||
gitea_tools.add_comment("owner", "repo", 1, "body")
|
||||
mock_client.add_comment.assert_called_with("owner", "repo", 1, "body")
|
||||
|
||||
# 24. add_label
|
||||
gitea_tools.add_label("owner", "repo", 1, "bug")
|
||||
mock_client.add_label.assert_called_with("owner", "repo", 1, "bug")
|
||||
|
||||
# 25. get_file_content_with_ref
|
||||
gitea_tools.get_file_content_with_ref("owner", "repo", "path", "ref")
|
||||
mock_client.get_file_content.assert_called_with("owner", "repo", "path", "ref")
|
||||
|
||||
# 26. update_file
|
||||
gitea_tools.update_file("owner", "repo", "path", "msg", "content", "branch")
|
||||
mock_client.update_file.assert_called_with("owner", "repo", "path", "msg", "content", "branch")
|
||||
@@ -0,0 +1,213 @@
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from gitea.client import GiteaClient
|
||||
from gitea.models import IssueModel, CommentModel, LabelModel, RepositoryModel
|
||||
from gitea.tools.issue_tools import IssueTools
|
||||
|
||||
|
||||
def test_get_issue_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
issue: IssueModel = IssueModel(number=1, title="Test Issue", state="open")
|
||||
mock_client.get_issue.return_value = issue
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.get_issue("owner", "repo", 1)
|
||||
|
||||
data: dict[str, Any] = json.loads(res)
|
||||
assert data["number"] == 1
|
||||
assert data["title"] == "Test Issue"
|
||||
mock_client.get_issue.assert_called_once_with("owner", "repo", 1)
|
||||
|
||||
|
||||
def test_get_issue_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.get_issue.side_effect = Exception("API Error")
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.get_issue("owner", "repo", 1)
|
||||
assert "Error getting issue: API Error" in res
|
||||
|
||||
|
||||
def test_close_issue_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.close_issue.return_value = IssueModel(number=1, state="closed")
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.close_issue("owner", "repo", 1)
|
||||
assert res == "Issue #1 closed successfully."
|
||||
mock_client.close_issue.assert_called_once_with("owner", "repo", 1)
|
||||
|
||||
|
||||
def test_close_issue_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.close_issue.side_effect = Exception("API Error")
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.close_issue("owner", "repo", 1)
|
||||
assert "Error closing issue: API Error" in res
|
||||
|
||||
|
||||
def test_get_issue_comments_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
comment: CommentModel = CommentModel(id=123, body="Comment body")
|
||||
mock_client.get_issue_comments.return_value = [comment]
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.get_issue_comments("owner", "repo", 1)
|
||||
data: list[dict[str, Any]] = json.loads(res)
|
||||
assert len(data) == 1
|
||||
assert data[0]["body"] == "Comment body"
|
||||
|
||||
|
||||
def test_get_issue_comments_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.get_issue_comments.side_effect = Exception("API Error")
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.get_issue_comments("owner", "repo", 1)
|
||||
assert "Error getting issue comments: API Error" in res
|
||||
|
||||
|
||||
def test_list_assigned_issues_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
repo: RepositoryModel = RepositoryModel(name="repo1", owner="owner1")
|
||||
issue: IssueModel = IssueModel(number=1, title="Test Issue")
|
||||
mock_client.list_all_user_repos.return_value = [repo]
|
||||
mock_client.list_assigned_issues.return_value = [issue]
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: list[dict[str, Any]] = issue_tools.list_assigned_issues()
|
||||
assert len(res) == 1
|
||||
assert res[0]["number"] == 1
|
||||
mock_client.list_all_user_repos.assert_called_once()
|
||||
mock_client.list_assigned_issues.assert_called_once_with("owner1", "repo1")
|
||||
|
||||
|
||||
def test_list_assigned_issues_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.list_all_user_repos.side_effect = Exception("API Error")
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: list[dict[str, Any]] = issue_tools.list_assigned_issues()
|
||||
assert res == []
|
||||
|
||||
|
||||
def test_list_issues_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
issue: IssueModel = IssueModel(number=1, title="Test Issue")
|
||||
mock_client.list_repo_issues.return_value = [issue]
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.list_issues("owner", "repo")
|
||||
assert "#1: Test Issue" in res
|
||||
|
||||
|
||||
def test_list_issues_empty() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.list_repo_issues.return_value = []
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.list_issues("owner", "repo")
|
||||
assert res == "No issues in owner/repo."
|
||||
|
||||
|
||||
def test_list_issues_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.list_repo_issues.side_effect = Exception("API Error")
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.list_issues("owner", "repo")
|
||||
assert "Error listing issues: API Error" in res
|
||||
|
||||
|
||||
def test_create_issue_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
issue: IssueModel = IssueModel(number=2)
|
||||
mock_client.create_issue.return_value = issue
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.create_issue("owner", "repo", "Title", "Body", ["label1"], ["assignee1"])
|
||||
assert res == "Issue #2 created successfully in owner/repo."
|
||||
mock_client.create_issue.assert_called_once_with("owner", "repo", "Title", "Body", ["label1"], ["assignee1"])
|
||||
|
||||
|
||||
def test_create_issue_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.create_issue.side_effect = Exception("API Error")
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.create_issue("owner", "repo", "Title", "Body")
|
||||
assert "Error creating issue: API Error" in res
|
||||
|
||||
|
||||
def test_add_label_to_issue_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.add_label.return_value = LabelModel(name="bug")
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug")
|
||||
assert res == "Label 'bug' added to issue #1."
|
||||
|
||||
|
||||
def test_add_label_to_issue_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.add_label.side_effect = Exception("API Error")
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug")
|
||||
assert "Error adding label to issue #1: API Error" in res
|
||||
|
||||
|
||||
def test_add_comment_to_issue_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.add_comment.return_value = CommentModel(id=1)
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.add_comment_to_issue("owner", "repo", 1, "body")
|
||||
assert res == "Comment added to issue #1."
|
||||
|
||||
|
||||
def test_add_comment_to_issue_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.add_comment.side_effect = Exception("API Error")
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.add_comment_to_issue("owner", "repo", 1, "body")
|
||||
assert "Error adding comment to issue #1: API Error" in res
|
||||
|
||||
|
||||
def test_add_comment_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.add_comment.return_value = CommentModel(id=1)
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.add_comment("owner", "repo", 1, "body")
|
||||
assert res == "Comment added to #1."
|
||||
|
||||
|
||||
def test_add_comment_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.add_comment.side_effect = Exception("API Error")
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.add_comment("owner", "repo", 1, "body")
|
||||
assert "Error adding comment: API Error" in res
|
||||
|
||||
|
||||
def test_add_label_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.add_label.return_value = LabelModel(name="bug")
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.add_label("owner", "repo", 1, "bug")
|
||||
assert res == "Label 'bug' added to #1."
|
||||
|
||||
|
||||
def test_add_label_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.add_label.side_effect = Exception("API Error")
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.add_label("owner", "repo", 1, "bug")
|
||||
assert "Error adding label: API Error" in res
|
||||
@@ -0,0 +1,83 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
from core.notification_tools import NotificationTools
|
||||
from core.notification_agent import NotificationReaderAgent, NotificationNoToolCalledError
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
def test_notification_tools() -> None:
|
||||
tools = NotificationTools()
|
||||
assert tools.tool_called is False
|
||||
assert tools.action == "NO_ACTION"
|
||||
|
||||
res = tools.process_issue("meeks", "repo", 42, "fix bug")
|
||||
assert tools.tool_called is True
|
||||
assert tools.action == "PROCESS_ISSUE"
|
||||
assert tools.arguments == {
|
||||
"owner": "meeks",
|
||||
"repo": "repo",
|
||||
"issue_number": 42,
|
||||
"reason": "fix bug",
|
||||
}
|
||||
assert "marked for processing" in res
|
||||
|
||||
tools = NotificationTools()
|
||||
res = tools.process_pr("meeks", "repo", 10, "review change")
|
||||
assert tools.tool_called is True
|
||||
assert tools.action == "PROCESS_PR"
|
||||
assert tools.arguments == {
|
||||
"owner": "meeks",
|
||||
"repo": "repo",
|
||||
"pr_number": 10,
|
||||
"reason": "review change",
|
||||
}
|
||||
assert "marked for processing" in res
|
||||
|
||||
tools = NotificationTools()
|
||||
res = tools.skip_notification("unrelated comments")
|
||||
assert tools.tool_called is True
|
||||
assert tools.action == "SKIP"
|
||||
assert tools.arguments == {"reason": "unrelated comments"}
|
||||
assert "marked to be skipped" in res
|
||||
|
||||
|
||||
@patch("core.notification_agent.NotificationReaderAgent.initialize")
|
||||
@patch("core.notification_agent.NotificationReaderAgent.run_with_tools")
|
||||
async def test_notification_reader_agent_success(
|
||||
mock_run_with_tools: MagicMock,
|
||||
mock_initialize: MagicMock
|
||||
) -> None:
|
||||
mock_initialize.return_value = None
|
||||
agent = NotificationReaderAgent("dummy-model")
|
||||
|
||||
# Mock tool call inside run_with_tools
|
||||
async def mock_run(mission: str, tools: list) -> str:
|
||||
# Simulate calling a tool
|
||||
for t in tools:
|
||||
if getattr(t, "__name__", "") == "process_issue":
|
||||
t("meeks", "repo", 42, "reason")
|
||||
return "response"
|
||||
|
||||
mock_run_with_tools.side_effect = mock_run
|
||||
|
||||
tools = NotificationTools()
|
||||
res = await agent.decide_notification("mission", [], tools)
|
||||
assert res == "response"
|
||||
assert tools.tool_called is True
|
||||
assert tools.action == "PROCESS_ISSUE"
|
||||
|
||||
|
||||
@patch("core.notification_agent.NotificationReaderAgent.initialize")
|
||||
@patch("core.notification_agent.NotificationReaderAgent.run_with_tools")
|
||||
async def test_notification_reader_agent_no_tool_error(
|
||||
mock_run_with_tools: MagicMock,
|
||||
mock_initialize: MagicMock
|
||||
) -> None:
|
||||
mock_initialize.return_value = None
|
||||
agent = NotificationReaderAgent("dummy-model")
|
||||
mock_run_with_tools.return_value = "no tool called"
|
||||
|
||||
tools = NotificationTools()
|
||||
with pytest.raises(NotificationNoToolCalledError):
|
||||
await agent.decide_notification("mission", [], tools)
|
||||
@@ -0,0 +1,149 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
from core.orchestrator import AgentOrchestrator
|
||||
from gitea.client import GiteaClient
|
||||
from gitea.tools.gitea_tools import GiteaTools
|
||||
from gitea.models import IssueModel, PullRequestModel, RepositoryModel
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_state_file(tmp_path: Path) -> Path:
|
||||
"""Fixture to mock state file path."""
|
||||
state_file = tmp_path / "agent_state.json"
|
||||
return state_file
|
||||
|
||||
|
||||
@patch("core.orchestrator.AgentOrchestrator._get_state_file_path")
|
||||
@patch("core.orchestrator.AgentDispatcher")
|
||||
@patch("core.orchestrator.WorkspaceManager")
|
||||
@patch("core.orchestrator.AgentFactory")
|
||||
async def test_poll_and_dispatch_no_notifications(
|
||||
mock_factory: MagicMock,
|
||||
mock_workspace_class: MagicMock,
|
||||
mock_dispatcher_class: MagicMock,
|
||||
mock_get_path: MagicMock,
|
||||
temp_state_file: Path
|
||||
) -> None:
|
||||
mock_get_path.return_value = temp_state_file
|
||||
mock_client = MagicMock(spec=GiteaClient)
|
||||
mock_tools = MagicMock(spec=GiteaTools)
|
||||
|
||||
# Return no notifications
|
||||
mock_client.list_unread_notifications.return_value = []
|
||||
|
||||
orchestrator = AgentOrchestrator(mock_client, mock_tools)
|
||||
await orchestrator.poll_and_dispatch()
|
||||
|
||||
mock_client.list_unread_notifications.assert_called_once_with(since=None)
|
||||
assert not temp_state_file.exists()
|
||||
|
||||
|
||||
@patch("core.orchestrator.AgentOrchestrator._get_state_file_path")
|
||||
@patch("core.orchestrator.AgentDispatcher")
|
||||
@patch("core.orchestrator.WorkspaceManager")
|
||||
@patch("core.orchestrator.AgentFactory")
|
||||
async def test_poll_and_dispatch_with_notifications(
|
||||
mock_factory: MagicMock,
|
||||
mock_workspace_class: MagicMock,
|
||||
mock_dispatcher_class: MagicMock,
|
||||
mock_get_path: MagicMock,
|
||||
temp_state_file: Path
|
||||
) -> None:
|
||||
mock_get_path.return_value = temp_state_file
|
||||
|
||||
mock_reader = MagicMock()
|
||||
async def mock_decide_notification(mission: str, inspection_tools: list, notification_tools) -> str:
|
||||
if "issue" in mission or "42" in mission:
|
||||
notification_tools.process_issue("meeks", "repo1", 42, "Needs processing")
|
||||
elif "pull" in mission or "10" in mission:
|
||||
notification_tools.process_pr("meeks", "repo1", 10, "Needs processing")
|
||||
else:
|
||||
notification_tools.skip_notification("Unrelated")
|
||||
return "Decided"
|
||||
mock_reader.decide_notification = AsyncMock(side_effect=mock_decide_notification)
|
||||
mock_factory.create_notification_reader_agent.return_value = mock_reader
|
||||
mock_client = MagicMock(spec=GiteaClient)
|
||||
mock_tools = MagicMock(spec=GiteaTools)
|
||||
|
||||
# Set up mock Gitea notifications
|
||||
notifications = [
|
||||
{
|
||||
"id": 101,
|
||||
"updated_at": "2026-06-30T10:00:00Z",
|
||||
"subject": {
|
||||
"type": "issue",
|
||||
"url": "https://gitea.meeks.freeddns.org/api/v1/repos/meeks/repo1/issues/42"
|
||||
},
|
||||
"repository": {
|
||||
"name": "repo1",
|
||||
"full_name": "meeks/repo1",
|
||||
"owner": {"login": "meeks"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"updated_at": "2026-06-30T11:00:00Z",
|
||||
"subject": {
|
||||
"type": "pull",
|
||||
"url": "https://gitea.meeks.freeddns.org/api/v1/repos/meeks/repo1/pulls/10"
|
||||
},
|
||||
"repository": {
|
||||
"name": "repo1",
|
||||
"full_name": "meeks/repo1",
|
||||
"owner": {"login": "meeks"}
|
||||
}
|
||||
}
|
||||
]
|
||||
mock_client.list_unread_notifications.return_value = notifications
|
||||
|
||||
# Mock issue and PR get methods
|
||||
issue_model = IssueModel(number=42, title="Bug issue", repository=RepositoryModel(name="repo1", full_name="meeks/repo1"))
|
||||
pr_model = PullRequestModel(number=10, title="Fix PR", repository=RepositoryModel(name="repo1", full_name="meeks/repo1"))
|
||||
mock_client.get_issue.return_value = issue_model
|
||||
mock_client.get_pull_request.return_value = pr_model
|
||||
|
||||
# Mock dispatcher and workspace path
|
||||
mock_dispatcher_instance = MagicMock()
|
||||
mock_dispatcher_instance.dispatch = AsyncMock(return_value=["Issue comment posted", "PR verified"])
|
||||
mock_dispatcher_class.return_value = mock_dispatcher_instance
|
||||
|
||||
mock_workspace_instance = MagicMock()
|
||||
mock_workspace_instance.get_repo_path.return_value.exists.return_value = True
|
||||
mock_workspace_class.return_value = mock_workspace_instance
|
||||
|
||||
# Create orchestrator and poll
|
||||
orchestrator = AgentOrchestrator(mock_client, mock_tools)
|
||||
await orchestrator.poll_and_dispatch()
|
||||
|
||||
# Assert notifications were checked with None (first execution)
|
||||
mock_client.list_unread_notifications.assert_called_once_with(since=None)
|
||||
|
||||
# Assert issue and PR details were fetched
|
||||
mock_client.get_issue.assert_called_once_with("meeks", "repo1", 42)
|
||||
mock_client.get_pull_request.assert_called_once_with("meeks", "repo1", 10)
|
||||
|
||||
# Assert work was processed by dispatcher
|
||||
mock_dispatcher_instance.dispatch.assert_called_once()
|
||||
work_items = mock_dispatcher_instance.dispatch.call_args[0][1]
|
||||
assert len(work_items) == 2
|
||||
assert work_items[0].task_number == 42
|
||||
assert work_items[0].notification_id == 101
|
||||
assert work_items[1].task_number == 10
|
||||
assert work_items[1].notification_id == 102
|
||||
|
||||
# Assert notifications were marked as read
|
||||
mock_client.mark_notification_as_read.assert_any_call(101)
|
||||
mock_client.mark_notification_as_read.assert_any_call(102)
|
||||
assert mock_client.mark_notification_as_read.call_count == 2
|
||||
|
||||
# Assert checkpoint date was persisted
|
||||
assert temp_state_file.exists()
|
||||
with open(temp_state_file, "r") as f:
|
||||
state = json.load(f)
|
||||
# Checkpoint should match latest updated_at
|
||||
assert state["last_checked"] == "2026-06-30T11:00:00Z"
|
||||
@@ -0,0 +1,232 @@
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from gitea.client import GiteaClient
|
||||
from gitea.models import PullRequestModel, CommentModel, RepositoryModel
|
||||
from gitea.tools.pr_tools import PRTools
|
||||
|
||||
|
||||
def test_get_pull_request_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
pr: PullRequestModel = PullRequestModel(number=1, title="Test PR", state="open")
|
||||
mock_client.get_pull_request.return_value = pr
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.get_pull_request("owner", "repo", 1)
|
||||
|
||||
data: dict[str, Any] = json.loads(res)
|
||||
assert data["number"] == 1
|
||||
assert data["title"] == "Test PR"
|
||||
mock_client.get_pull_request.assert_called_once_with("owner", "repo", 1)
|
||||
|
||||
|
||||
def test_get_pull_request_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.get_pull_request.side_effect = Exception("API Error")
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.get_pull_request("owner", "repo", 1)
|
||||
assert "Error getting pull request: API Error" in res
|
||||
|
||||
|
||||
def test_close_pull_request_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.close_pull_request.return_value = PullRequestModel(number=1, state="closed")
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.close_pull_request("owner", "repo", 1)
|
||||
assert res == "Pull request #1 closed successfully."
|
||||
mock_client.close_pull_request.assert_called_once_with("owner", "repo", 1)
|
||||
|
||||
|
||||
def test_close_pull_request_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.close_pull_request.side_effect = Exception("API Error")
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.close_pull_request("owner", "repo", 1)
|
||||
assert "Error closing pull request: API Error" in res
|
||||
|
||||
|
||||
def test_get_pull_request_comments_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
comment: CommentModel = CommentModel(id=123, body="Comment body")
|
||||
mock_client.get_pull_request_comments.return_value = [comment]
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.get_pull_request_comments("owner", "repo", 1)
|
||||
data: list[dict[str, Any]] = json.loads(res)
|
||||
assert len(data) == 1
|
||||
assert data[0]["body"] == "Comment body"
|
||||
|
||||
|
||||
def test_get_pull_request_comments_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.get_pull_request_comments.side_effect = Exception("API Error")
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.get_pull_request_comments("owner", "repo", 1)
|
||||
assert "Error getting PR comments: API Error" in res
|
||||
|
||||
|
||||
def test_list_assigned_pull_requests_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
repo: RepositoryModel = RepositoryModel(name="repo1", owner="owner1")
|
||||
pr: PullRequestModel = PullRequestModel(number=1, title="Test PR")
|
||||
mock_client.list_all_user_repos.return_value = [repo]
|
||||
mock_client.list_assigned_pull_requests.return_value = [pr]
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: list[dict[str, Any]] = pr_tools.list_assigned_pull_requests()
|
||||
assert len(res) == 1
|
||||
assert res[0]["number"] == 1
|
||||
mock_client.list_all_user_repos.assert_called_once()
|
||||
mock_client.list_assigned_pull_requests.assert_called_once_with("owner1", "repo1")
|
||||
|
||||
|
||||
def test_list_assigned_pull_requests_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.list_all_user_repos.side_effect = Exception("API Error")
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: list[dict[str, Any]] = pr_tools.list_assigned_pull_requests()
|
||||
assert res == []
|
||||
|
||||
|
||||
def test_list_pull_requests_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
pr: PullRequestModel = PullRequestModel(number=1, title="Test PR")
|
||||
mock_client.list_repo_pull_requests.return_value = [pr]
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.list_pull_requests("owner", "repo")
|
||||
assert "#1: Test PR" in res
|
||||
|
||||
|
||||
def test_list_pull_requests_empty() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.list_repo_pull_requests.return_value = []
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.list_pull_requests("owner", "repo")
|
||||
assert res == "No PRs in owner/repo."
|
||||
|
||||
|
||||
def test_list_pull_requests_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.list_repo_pull_requests.side_effect = Exception("API Error")
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.list_pull_requests("owner", "repo")
|
||||
assert "Error listing PRs: API Error" in res
|
||||
|
||||
|
||||
def test_create_pull_request_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
pr: PullRequestModel = PullRequestModel(number=2, title="Title")
|
||||
mock_client.create_pr_via_tea.return_value = pr
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.create_pull_request("owner", "repo", "head", "base", "Title", "Desc")
|
||||
data: dict[str, Any] = json.loads(res)
|
||||
assert data["number"] == 2
|
||||
mock_client.create_pr_via_tea.assert_called_once_with("owner", "repo", "Title", "Desc", "head", "base")
|
||||
|
||||
|
||||
def test_create_pull_request_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.create_pr_via_tea.side_effect = Exception("API Error")
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.create_pull_request("owner", "repo", "head", "base", "Title")
|
||||
assert "Error creating PR: API Error" in res
|
||||
|
||||
|
||||
def test_add_label_to_pr_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.add_label_pr.return_value = {}
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug")
|
||||
assert res == "Label 'bug' added to PR #1."
|
||||
|
||||
|
||||
def test_add_label_to_pr_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.add_label_pr.side_effect = Exception("API Error")
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug")
|
||||
assert "Error adding label to PR #1: API Error" in res
|
||||
|
||||
|
||||
def test_get_pull_request_diff_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.get_pull_request_diff.return_value = "diff content"
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.get_pull_request_diff("owner", "repo", 1)
|
||||
assert res == "diff content"
|
||||
|
||||
|
||||
def test_get_pull_request_diff_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.get_pull_request_diff.side_effect = Exception("API Error")
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.get_pull_request_diff("owner", "repo", 1)
|
||||
assert "Error getting PR diff: API Error" in res
|
||||
|
||||
|
||||
def test_get_pull_request_patch_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.get_pull_request_patch.return_value = "patch content"
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.get_pull_request_patch("owner", "repo", 1)
|
||||
assert res == "patch content"
|
||||
|
||||
|
||||
def test_get_pull_request_patch_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.get_pull_request_patch.side_effect = Exception("API Error")
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.get_pull_request_patch("owner", "repo", 1)
|
||||
assert "Error getting PR patch: API Error" in res
|
||||
|
||||
|
||||
def test_approve_pull_request_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.approve_pr.return_value = {}
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good")
|
||||
assert res == "Approved PR #1."
|
||||
|
||||
|
||||
def test_approve_pull_request_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.approve_pr.side_effect = Exception("API Error")
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good")
|
||||
assert "Error approving PR: API Error" in res
|
||||
|
||||
|
||||
def test_request_changes_success() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.request_changes_pr.return_value = {}
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.request_changes("owner", "repo", 1, "bad")
|
||||
assert res == "Requested changes on PR #1."
|
||||
|
||||
|
||||
def test_request_changes_failure() -> None:
|
||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||
mock_client.request_changes_pr.side_effect = Exception("API Error")
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.request_changes("owner", "repo", 1, "bad")
|
||||
assert "Error requesting changes: API Error" in res
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Tests for ResearchTools: web_search and fetch_url."""
|
||||
|
||||
import json
|
||||
import httpx
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
|
||||
import pytest
|
||||
from gitea.tools.research_tools import ResearchTools, _MAX_CONTENT_CHARS
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tools() -> ResearchTools:
|
||||
return ResearchTools()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# _smart_truncate
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
|
||||
class TestSmartTruncate:
|
||||
def test_no_truncation_if_short(self, tools: ResearchTools) -> None:
|
||||
text = "hello world"
|
||||
assert tools._smart_truncate(text, max_chars=100) == text
|
||||
|
||||
def test_cuts_at_paragraph_boundary(self, tools: ResearchTools) -> None:
|
||||
# Two paragraphs; the boundary falls after the 70% mark of max_chars
|
||||
para1 = "A" * 80
|
||||
para2 = "B" * 80
|
||||
text = para1 + "\n\n" + para2
|
||||
result = tools._smart_truncate(text, max_chars=100)
|
||||
# Should cut at the \n\n, not mid-word
|
||||
assert "truncated" in result
|
||||
assert result.startswith(para1)
|
||||
|
||||
def test_hard_cut_when_no_good_boundary(self, tools: ResearchTools) -> None:
|
||||
# Single block — no paragraph boundary available
|
||||
text = "x" * 200
|
||||
result = tools._smart_truncate(text, max_chars=100)
|
||||
assert "truncated" in result
|
||||
assert result.startswith("x" * 100)
|
||||
|
||||
def test_exact_length_not_truncated(self, tools: ResearchTools) -> None:
|
||||
text = "a" * 100
|
||||
assert tools._smart_truncate(text, max_chars=100) == text
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# _html_to_markdown — regex fallback (no optional deps needed)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
|
||||
class TestHtmlToMarkdown:
|
||||
def _patch_imports(self, tools: ResearchTools) -> str:
|
||||
"""Return result when optional deps are unavailable."""
|
||||
# Force all optional imports to fail → regex fallback
|
||||
import builtins
|
||||
real_import = builtins.__import__
|
||||
|
||||
def mock_import(name: str, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||
if name in ("trafilatura", "readability", "markdownify"):
|
||||
raise ImportError(f"mocked missing: {name}")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
return tools._html_to_markdown("<p>Hello <b>world</b></p>")
|
||||
|
||||
def test_regex_fallback_removes_tags(self, tools: ResearchTools) -> None:
|
||||
result = self._patch_imports(tools)
|
||||
assert "Hello" in result
|
||||
assert "world" in result
|
||||
assert "<p>" not in result
|
||||
assert "<b>" not in result
|
||||
|
||||
def test_regex_fallback_removes_script(self, tools: ResearchTools) -> None:
|
||||
import builtins
|
||||
real_import = builtins.__import__
|
||||
|
||||
def mock_import(name: str, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||
if name in ("trafilatura", "readability", "markdownify"):
|
||||
raise ImportError
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
result = tools._html_to_markdown("<script>evil()</script>visible")
|
||||
assert "evil" not in result
|
||||
assert "visible" in result
|
||||
|
||||
def test_regex_fallback_decodes_entities(self, tools: ResearchTools) -> None:
|
||||
import builtins
|
||||
real_import = builtins.__import__
|
||||
|
||||
def mock_import(name: str, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||
if name in ("trafilatura", "readability", "markdownify"):
|
||||
raise ImportError
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
result = tools._html_to_markdown("Tom & Jerry <3>")
|
||||
assert "Tom & Jerry <3>" in result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# _format_results
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
|
||||
class TestFormatResults:
|
||||
def test_numbered_list(self, tools: ResearchTools) -> None:
|
||||
results = [
|
||||
{"title": "Title A", "href": "https://a.com", "body": "Snippet A"},
|
||||
{"title": "Title B", "href": "https://b.com", "body": "Snippet B"},
|
||||
]
|
||||
output = tools._format_results(results, "test query")
|
||||
assert "[1]" in output
|
||||
assert "[2]" in output
|
||||
assert "https://a.com" in output
|
||||
assert "Snippet A" in output
|
||||
|
||||
def test_includes_date_when_present(self, tools: ResearchTools) -> None:
|
||||
results = [{"title": "T", "href": "https://x.com", "body": "S", "published_date": "2024-01"}]
|
||||
output = tools._format_results(results, "q")
|
||||
assert "Date: 2024-01" in output
|
||||
|
||||
def test_no_date_field_when_absent(self, tools: ResearchTools) -> None:
|
||||
results = [{"title": "T", "href": "https://x.com", "body": "S"}]
|
||||
output = tools._format_results(results, "q")
|
||||
assert "Date:" not in output
|
||||
|
||||
def test_snippet_truncated_to_250_chars(self, tools: ResearchTools) -> None:
|
||||
long_body = "x" * 500
|
||||
results = [{"title": "T", "href": "u", "body": long_body}]
|
||||
output = tools._format_results(results, "q")
|
||||
assert "x" * 251 not in output # body was truncated before formatting
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# _search_searxng
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
|
||||
class TestSearchSearxng:
|
||||
def _make_searxng_response(self, results: list[dict]) -> MagicMock:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"results": results}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
return mock_resp
|
||||
|
||||
@patch("gitea.tools.research_tools.httpx.Client")
|
||||
def test_returns_formatted_results_on_success(
|
||||
self, mock_cls: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
results = [
|
||||
{"title": "SearXNG Result", "url": "https://example.com", "content": "snippet"},
|
||||
]
|
||||
mock_cls.return_value.__enter__.return_value.get.return_value = (
|
||||
self._make_searxng_response(results)
|
||||
)
|
||||
output = tools._search_searxng("test query", num_results=5)
|
||||
assert output is not None
|
||||
assert "[1]" in output
|
||||
assert "example.com" in output
|
||||
|
||||
@patch("gitea.tools.research_tools.httpx.Client")
|
||||
def test_returns_none_when_empty_results(
|
||||
self, mock_cls: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
mock_cls.return_value.__enter__.return_value.get.return_value = (
|
||||
self._make_searxng_response([])
|
||||
)
|
||||
assert tools._search_searxng("nothing", num_results=5) is None
|
||||
|
||||
@patch("gitea.tools.research_tools.httpx.Client")
|
||||
def test_returns_none_on_connection_error(
|
||||
self, mock_cls: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
mock_cls.return_value.__enter__.return_value.get.side_effect = (
|
||||
httpx.ConnectError("refused")
|
||||
)
|
||||
assert tools._search_searxng("query", num_results=5) is None
|
||||
|
||||
@patch("gitea.tools.research_tools.httpx.Client")
|
||||
def test_respects_time_range_param(
|
||||
self, mock_cls: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
mock_cls.return_value.__enter__.return_value.get.return_value = (
|
||||
self._make_searxng_response([])
|
||||
)
|
||||
tools._search_searxng("q", num_results=5, time_range="month")
|
||||
call_kwargs = mock_cls.return_value.__enter__.return_value.get.call_args
|
||||
params = call_kwargs[1].get("params", call_kwargs[0][1] if len(call_kwargs[0]) > 1 else {})
|
||||
assert params.get("time_range") == "month"
|
||||
|
||||
@patch("gitea.tools.research_tools.httpx.Client")
|
||||
def test_passes_basic_auth_if_configured(
|
||||
self, mock_cls: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
mock_cls.return_value.__enter__.return_value.get.return_value = (
|
||||
self._make_searxng_response([])
|
||||
)
|
||||
with patch("gitea.tools.research_tools._SEARXNG_USERNAME", "user"), \
|
||||
patch("gitea.tools.research_tools._SEARXNG_PASSWORD", "pass"):
|
||||
tools._search_searxng("q", num_results=5)
|
||||
|
||||
mock_cls.assert_called_once()
|
||||
kwargs = mock_cls.call_args[1]
|
||||
assert kwargs.get("auth") == ("user", "pass")
|
||||
|
||||
@patch("gitea.tools.research_tools.httpx.Client")
|
||||
def test_web_search_uses_searxng_first(
|
||||
self, mock_cls: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
"""web_search should return SearXNG results without touching DDGS."""
|
||||
results = [{"title": "From SearXNG", "url": "https://sx.com", "content": "content"}]
|
||||
mock_cls.return_value.__enter__.return_value.get.return_value = (
|
||||
self._make_searxng_response(results)
|
||||
)
|
||||
output = tools.web_search("python typing")
|
||||
assert "From SearXNG" in output or "[1]" in output
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# web_search — with DDGS mocked
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
|
||||
class TestWebSearch:
|
||||
def _make_ddgs_result(self) -> list[dict[str, str]]:
|
||||
return [
|
||||
{"title": "Foo Docs", "href": "https://foo.com/docs", "body": "Learn about Foo."},
|
||||
{"title": "Bar Guide", "href": "https://bar.com", "body": "A guide to Bar."},
|
||||
]
|
||||
|
||||
def _mock_ddgs(self, results: list[dict[str, str]]) -> MagicMock:
|
||||
mock_ddgs_instance = MagicMock()
|
||||
mock_ddgs_instance.__enter__ = MagicMock(return_value=mock_ddgs_instance)
|
||||
mock_ddgs_instance.__exit__ = MagicMock(return_value=False)
|
||||
mock_ddgs_instance.text.return_value = iter(results)
|
||||
return mock_ddgs_instance
|
||||
|
||||
@patch("gitea.tools.research_tools.time.sleep")
|
||||
def test_returns_numbered_results(
|
||||
self, _mock_sleep: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
mock_cls = MagicMock(return_value=self._mock_ddgs(self._make_ddgs_result()))
|
||||
with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \
|
||||
patch("gitea.tools.research_tools.DDGS", mock_cls):
|
||||
result = tools.web_search("python httpx")
|
||||
assert "[1]" in result
|
||||
assert "foo.com" in result
|
||||
|
||||
@patch("gitea.tools.research_tools.time.sleep")
|
||||
def test_no_results_returns_helpful_message(
|
||||
self, _mock_sleep: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
mock_cls = MagicMock(return_value=self._mock_ddgs([]))
|
||||
with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \
|
||||
patch("gitea.tools.research_tools.DDGS", mock_cls):
|
||||
result = tools.web_search("xyzzy-not-real")
|
||||
assert "No results" in result
|
||||
|
||||
def test_clamps_num_results_max(self, tools: ResearchTools) -> None:
|
||||
ddgs_mock = self._mock_ddgs(self._make_ddgs_result())
|
||||
mock_cls = MagicMock(return_value=ddgs_mock)
|
||||
with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \
|
||||
patch("gitea.tools.research_tools.DDGS", mock_cls):
|
||||
tools.web_search("q", num_results=999)
|
||||
# DDGS.text should be called with max_results clamped to 20
|
||||
ddgs_mock.text.assert_called_once()
|
||||
_, kwargs = ddgs_mock.text.call_args
|
||||
assert kwargs.get("max_results", 0) <= 20
|
||||
|
||||
def test_clamps_num_results_min(self, tools: ResearchTools) -> None:
|
||||
ddgs_mock = self._mock_ddgs(self._make_ddgs_result())
|
||||
mock_cls = MagicMock(return_value=ddgs_mock)
|
||||
with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \
|
||||
patch("gitea.tools.research_tools.DDGS", mock_cls):
|
||||
tools.web_search("q", num_results=0)
|
||||
_, kwargs = ddgs_mock.text.call_args
|
||||
assert kwargs.get("max_results", 0) >= 1
|
||||
|
||||
@patch("gitea.tools.research_tools.time.sleep")
|
||||
def test_retries_on_rate_limit(
|
||||
self, mock_sleep: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
"""Should retry up to 3 times with exponential backoff on RatelimitException."""
|
||||
rate_exc = Exception("rate limit")
|
||||
ddgs_mock = MagicMock()
|
||||
ddgs_mock.__enter__ = MagicMock(return_value=ddgs_mock)
|
||||
ddgs_mock.__exit__ = MagicMock(return_value=False)
|
||||
ddgs_mock.text.side_effect = rate_exc
|
||||
|
||||
with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \
|
||||
patch("gitea.tools.research_tools.DDGS", return_value=ddgs_mock), \
|
||||
patch("gitea.tools.research_tools.RatelimitException", type(rate_exc)), \
|
||||
patch("gitea.tools.research_tools.DuckDuckGoSearchException", ValueError):
|
||||
result = tools.web_search("q")
|
||||
|
||||
assert isinstance(result, str) # Returns error string, not raise
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# fetch_url
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
|
||||
class TestFetchUrl:
|
||||
def _make_response(
|
||||
self,
|
||||
text: str,
|
||||
content_type: str = "text/html; charset=utf-8",
|
||||
status_code: int = 200,
|
||||
) -> MagicMock:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.text = text
|
||||
mock_resp.headers = {"content-type": content_type}
|
||||
mock_resp.status_code = status_code
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
return mock_resp
|
||||
|
||||
def test_rejects_non_http_url(self, tools: ResearchTools) -> None:
|
||||
result = tools.fetch_url("ftp://example.com/file")
|
||||
assert "Invalid URL" in result
|
||||
|
||||
def test_rejects_no_scheme(self, tools: ResearchTools) -> None:
|
||||
result = tools.fetch_url("example.com")
|
||||
assert "Invalid URL" in result
|
||||
|
||||
@patch("gitea.tools.research_tools.httpx.Client")
|
||||
def test_pretty_prints_json_response(
|
||||
self, mock_cls: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
data = {"key": "value", "n": 42}
|
||||
mock_resp = self._make_response(
|
||||
json.dumps(data), content_type="application/json"
|
||||
)
|
||||
mock_resp.json = MagicMock(return_value=data)
|
||||
mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||
|
||||
result = tools.fetch_url("https://api.example.com/data")
|
||||
assert '"key": "value"' in result
|
||||
|
||||
@patch("gitea.tools.research_tools.httpx.Client")
|
||||
def test_returns_plain_text_as_is(
|
||||
self, mock_cls: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
mock_resp = self._make_response("plain text content", content_type="text/plain")
|
||||
mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||
|
||||
result = tools.fetch_url("https://example.com/readme.txt")
|
||||
assert "plain text content" in result
|
||||
|
||||
@patch("gitea.tools.research_tools.httpx.Client")
|
||||
def test_returns_markdown_file_as_is(
|
||||
self, mock_cls: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
mock_resp = self._make_response("# Heading\nContent", content_type="text/plain")
|
||||
mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||
|
||||
result = tools.fetch_url("https://example.com/README.md")
|
||||
assert "# Heading" in result
|
||||
|
||||
@patch("gitea.tools.research_tools.httpx.Client")
|
||||
def test_raw_html_when_extract_false(
|
||||
self, mock_cls: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
html = "<html><body><p>Content</p></body></html>"
|
||||
mock_resp = self._make_response(html)
|
||||
mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||
|
||||
result = tools.fetch_url("https://example.com", extract_text=False)
|
||||
assert "<p>" in result
|
||||
|
||||
@patch("gitea.tools.research_tools.httpx.Client")
|
||||
def test_truncates_at_max_chars(
|
||||
self, mock_cls: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
mock_resp = self._make_response("word " * 10000, content_type="text/plain")
|
||||
mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||
|
||||
result = tools.fetch_url("https://example.com/big", max_chars=100)
|
||||
assert "truncated" in result
|
||||
|
||||
@patch("gitea.tools.research_tools.httpx.Client")
|
||||
def test_handles_http_404(
|
||||
self, mock_cls: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
import httpx as _httpx
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
mock_cls.return_value.__enter__.return_value.get.side_effect = (
|
||||
_httpx.HTTPStatusError("not found", request=MagicMock(), response=mock_response)
|
||||
)
|
||||
result = tools.fetch_url("https://example.com/missing")
|
||||
assert "404" in result or "Failed" in result
|
||||
|
||||
@patch("gitea.tools.research_tools.httpx.Client")
|
||||
def test_handles_timeout(
|
||||
self, mock_cls: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
import httpx as _httpx
|
||||
|
||||
mock_cls.return_value.__enter__.return_value.get.side_effect = (
|
||||
_httpx.TimeoutException("timed out")
|
||||
)
|
||||
result = tools.fetch_url("https://slow.example.com")
|
||||
assert "timed out" in result.lower() or "timeout" in result.lower()
|
||||
|
||||
@patch("gitea.tools.research_tools.httpx.Client")
|
||||
def test_html_extraction_called_for_html_content(
|
||||
self, mock_cls: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
html = "<html><body><p>Hello world from main content</p></body></html>"
|
||||
mock_resp = self._make_response(html)
|
||||
mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||
|
||||
with patch.object(tools, "_html_to_markdown", return_value="Hello world") as mock_extract:
|
||||
result = tools.fetch_url("https://example.com")
|
||||
mock_extract.assert_called_once_with(html)
|
||||
assert "Hello world" in result
|
||||
|
||||
@patch("gitea.tools.research_tools.httpx.Client")
|
||||
def test_default_max_chars_is_20k(
|
||||
self, mock_cls: MagicMock, tools: ResearchTools
|
||||
) -> None:
|
||||
mock_resp = self._make_response("a" * 30000, content_type="text/plain")
|
||||
mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||
|
||||
result = tools.fetch_url("https://example.com/long")
|
||||
assert "truncated" in result
|
||||
# Content before truncation note should be ~20k chars
|
||||
content_before = result.split("[Content truncated")[0]
|
||||
# Allow a small buffer for the trailing \n\n appended before the truncation note
|
||||
assert len(content_before) <= _MAX_CONTENT_CHARS + 4
|
||||
Reference in New Issue
Block a user