Refactor code structure for improved readability and maintainability

This commit is contained in:
mi222eh
2026-07-06 21:37:20 +02:00
parent 6ca6a5687a
commit 5f35f56edc
48 changed files with 8128 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Tests package.
+158
View File
@@ -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]
+124
View File
@@ -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"}
+147
View File
@@ -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
+544
View File
@@ -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 -->"
)
+97
View File
@@ -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
+22
View File
@@ -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"
+113
View File
@@ -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")
+213
View File
@@ -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
+83
View File
@@ -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)
+149
View File
@@ -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"
+232
View File
@@ -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
+436
View File
@@ -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 &amp; Jerry &lt;3&gt;")
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