ae4e2d46ac
- Replace Any with object or specific types across codebase - Add ReviewRequest dataclass for PR review payloads - Update bad_code.md: mark 5.1 (Any Type Overuse) as resolved - Fix summary table with accurate counts and unresolved issues list
729 lines
27 KiB
Python
729 lines
27 KiB
Python
import pytest
|
|
from unittest.mock import MagicMock, AsyncMock, patch, ANY
|
|
from core.dispatcher import AgentDispatcher
|
|
from core.queue import WorkItem
|
|
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()
|
|
mock_client.repos = MagicMock()
|
|
|
|
# Mock sub-clients
|
|
mock_client.prs = MagicMock()
|
|
mock_client.issues = MagicMock()
|
|
mock_client.notifications = MagicMock()
|
|
|
|
# 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.prs.list_repo_pull_requests.return_value = [pr]
|
|
|
|
dispatcher = AgentDispatcher(
|
|
client=mock_client,
|
|
issue_tools=MagicMock(),
|
|
pr_tools=MagicMock(),
|
|
file_tools=MagicMock(),
|
|
git_tools=MagicMock(),
|
|
)
|
|
|
|
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.prs.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()
|
|
mock_client.repos = MagicMock()
|
|
mock_tools: MagicMock = MagicMock()
|
|
|
|
# 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.prs.list_repo_pull_requests.return_value = [pr]
|
|
mock_client.issues.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,
|
|
issue_tools=MagicMock(),
|
|
pr_tools=MagicMock(),
|
|
file_tools=MagicMock(),
|
|
git_tools=MagicMock(),
|
|
)
|
|
|
|
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()
|
|
mock_client.repos = MagicMock()
|
|
mock_tools: MagicMock = MagicMock()
|
|
|
|
# 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.prs.get_pull_request.return_value = pr
|
|
mock_client.prs.get_pull_request_diff.return_value = "diff context"
|
|
mock_client.prs.get_pull_request_files.return_value = []
|
|
mock_client.prs.get_pull_request_comments.return_value = []
|
|
|
|
# Mock the connected issue and its comments
|
|
issue = IssueModel(number=42, title="bug description")
|
|
mock_client.issues.get_issue.return_value = issue
|
|
|
|
comment = CommentModel(id=1, body="First comment")
|
|
mock_client.issues.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
|
|
|
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
|
dispatcher = AgentDispatcher(
|
|
client=mock_client,
|
|
issue_tools=MagicMock(),
|
|
pr_tools=MagicMock(),
|
|
file_tools=MagicMock(),
|
|
git_tools=MagicMock(),
|
|
)
|
|
|
|
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.issues.get_issue.assert_called_once_with("meeks", "repo1", 42)
|
|
mock_client.issues.get_issue_comments.assert_called_once_with("meeks", "repo1", 42)
|
|
|
|
|
|
async def test_find_pr_for_issue_by_branch() -> None:
|
|
mock_client: MagicMock = MagicMock()
|
|
mock_client.repos = MagicMock()
|
|
mock_tools: MagicMock = MagicMock()
|
|
|
|
dispatcher = AgentDispatcher(
|
|
client=mock_client,
|
|
issue_tools=MagicMock(),
|
|
pr_tools=MagicMock(),
|
|
file_tools=MagicMock(),
|
|
git_tools=MagicMock(),
|
|
)
|
|
|
|
# 1. Matches fix/issue-42-some-desc
|
|
pr1 = PullRequestModel(number=102, head={"ref": "fix/issue-42-some-desc"})
|
|
mock_client.prs.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.prs.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.prs.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.prs.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.prs.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()
|
|
mock_client.repos = MagicMock()
|
|
mock_tools: MagicMock = MagicMock()
|
|
|
|
# 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.prs.list_repo_pull_requests.return_value = [pr]
|
|
|
|
dispatcher = AgentDispatcher(
|
|
client=mock_client,
|
|
issue_tools=MagicMock(),
|
|
pr_tools=MagicMock(),
|
|
file_tools=MagicMock(),
|
|
git_tools=MagicMock(),
|
|
)
|
|
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()
|
|
mock_client.repos = MagicMock()
|
|
mock_client.repos = MagicMock()
|
|
mock_client.prs = MagicMock()
|
|
mock_client.issues = MagicMock()
|
|
mock_client.notifications = MagicMock()
|
|
mock_tools: MagicMock = MagicMock()
|
|
|
|
from gitea.models import UserModel
|
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
|
pr = PullRequestModel(
|
|
number=104,
|
|
title="already reviewed PR",
|
|
body="closes #42",
|
|
user=UserModel(login="unknown-ai"),
|
|
requested_reviewers=[],
|
|
)
|
|
mock_client.prs.get_pull_request.return_value = pr
|
|
mock_client.prs.get_pull_request_diff.return_value = "diff"
|
|
mock_client.prs.get_pull_request_comments.return_value = [
|
|
CommentModel(id=1, body="Reviewed by AI Agent: Looks good.")
|
|
]
|
|
|
|
dispatcher = AgentDispatcher(
|
|
client=mock_client,
|
|
issue_tools=MagicMock(),
|
|
pr_tools=MagicMock(),
|
|
file_tools=MagicMock(),
|
|
git_tools=MagicMock(),
|
|
)
|
|
|
|
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 _make_dispatcher_for_reply_tests() -> AgentDispatcher:
|
|
mock_client = MagicMock()
|
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
|
return AgentDispatcher(client=mock_client, tools=MagicMock())
|
|
|
|
|
|
def test_is_awaiting_reply_no_comments() -> None:
|
|
dispatcher = _make_dispatcher_for_reply_tests()
|
|
assert dispatcher._is_awaiting_reply([]) is False
|
|
|
|
|
|
def test_is_awaiting_reply_no_question() -> None:
|
|
dispatcher = _make_dispatcher_for_reply_tests()
|
|
comments = [_make_comment("unknown-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 = _make_dispatcher_for_reply_tests()
|
|
body = "Should I use approach A or B?\n<!-- agent:awaiting-reply -->"
|
|
comments = [_make_comment("unknown-ai", body)]
|
|
assert dispatcher._is_awaiting_reply(comments) is True
|
|
|
|
|
|
def test_is_awaiting_reply_agent_question_human_replied() -> None:
|
|
dispatcher = _make_dispatcher_for_reply_tests()
|
|
body = "Should I use approach A or B?\n<!-- agent:awaiting-reply -->"
|
|
comments = [
|
|
_make_comment("unknown-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 = _make_dispatcher_for_reply_tests()
|
|
comments = [_make_comment("unknown-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()
|
|
mock_client.repos = MagicMock()
|
|
mock_tools: MagicMock = MagicMock()
|
|
|
|
mock_client.prs.list_repo_pull_requests.return_value = []
|
|
mock_client.issues.get_issue_comments.return_value = []
|
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-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,
|
|
issue_tools=MagicMock(),
|
|
pr_tools=MagicMock(),
|
|
file_tools=MagicMock(),
|
|
git_tools=MagicMock(),
|
|
)
|
|
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.issues.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()
|
|
mock_client.repos = MagicMock()
|
|
mock_tools: MagicMock = MagicMock()
|
|
|
|
mock_client.prs.list_repo_pull_requests.return_value = []
|
|
mock_client.issues.get_issue_comments.return_value = []
|
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-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,
|
|
issue_tools=MagicMock(),
|
|
pr_tools=MagicMock(),
|
|
file_tools=MagicMock(),
|
|
git_tools=MagicMock(),
|
|
)
|
|
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.issues.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()
|
|
mock_client.repos = MagicMock()
|
|
mock_tools: MagicMock = MagicMock()
|
|
|
|
mock_client.prs.list_repo_pull_requests.return_value = []
|
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
|
|
|
# Human comments indicating satisfaction after our answer
|
|
mock_client.issues.get_issue_comments.return_value = [
|
|
_make_comment("unknown-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,
|
|
issue_tools=MagicMock(),
|
|
pr_tools=MagicMock(),
|
|
file_tools=MagicMock(),
|
|
git_tools=MagicMock(),
|
|
)
|
|
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.issues.add_comment.assert_called_once_with("meeks", "repo1", 42, "Closing the issue now. Let me know if you need anything else!")
|
|
mock_client.issues.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()
|
|
mock_client.repos = MagicMock()
|
|
mock_tools: MagicMock = MagicMock()
|
|
|
|
mock_client.prs.list_repo_pull_requests.return_value = []
|
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
|
mock_client.issues.get_issue_comments.return_value = [
|
|
_make_comment("unknown-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.prs.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,
|
|
issue_tools=MagicMock(),
|
|
pr_tools=MagicMock(),
|
|
file_tools=MagicMock(),
|
|
git_tools=MagicMock(),
|
|
)
|
|
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.prs.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.issues.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()
|
|
mock_client.repos = MagicMock()
|
|
mock_tools: MagicMock = MagicMock()
|
|
|
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-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.prs.list_repo_pull_requests.return_value = [wip_pr]
|
|
mock_client.prs.get_pr_reviews.return_value = []
|
|
|
|
mock_client.issues.get_issue_comments.return_value = [
|
|
_make_comment("unknown-ai", "### Proposed Plan\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
|
|
_make_comment("michael", "looks good, go ahead")
|
|
]
|
|
mock_client.prs.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,
|
|
issue_tools=MagicMock(),
|
|
pr_tools=MagicMock(),
|
|
file_tools=MagicMock(),
|
|
git_tools=MagicMock(),
|
|
)
|
|
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.prs.create_pull_request.assert_not_called()
|
|
|
|
|
|
async def test_dispatch_skips_pr_if_not_requested_reviewer() -> None:
|
|
mock_client: MagicMock = MagicMock()
|
|
mock_client.repos = MagicMock()
|
|
mock_tools: MagicMock = MagicMock()
|
|
|
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-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.prs.get_pull_request.return_value = pr_detail
|
|
|
|
dispatcher = AgentDispatcher(
|
|
client=mock_client,
|
|
issue_tools=MagicMock(),
|
|
pr_tools=MagicMock(),
|
|
file_tools=MagicMock(),
|
|
git_tools=MagicMock(),
|
|
)
|
|
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()
|
|
mock_client.repos = MagicMock()
|
|
mock_tools: MagicMock = MagicMock()
|
|
|
|
mock_client.prs.list_repo_pull_requests.return_value = []
|
|
mock_client.issues.get_issue_comments.return_value = []
|
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-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,
|
|
issue_tools=MagicMock(),
|
|
pr_tools=MagicMock(),
|
|
file_tools=MagicMock(),
|
|
git_tools=MagicMock(),
|
|
)
|
|
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.issues.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 -->"
|
|
)
|
|
|
|
|
|
async def test_dispatcher_raises_type_error_for_invalid_task_info() -> None:
|
|
mock_client: MagicMock = MagicMock()
|
|
mock_client.repos = MagicMock()
|
|
mock_tools: MagicMock = MagicMock()
|
|
|
|
# Mock return values for methods called prior to the isinstance check
|
|
mock_client.prs.list_repo_pull_requests.return_value = []
|
|
mock_client.issues.get_issue_comments.return_value = []
|
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
|
|
|
dispatcher = AgentDispatcher(
|
|
client=mock_client,
|
|
issue_tools=MagicMock(),
|
|
pr_tools=MagicMock(),
|
|
file_tools=MagicMock(),
|
|
git_tools=MagicMock(),
|
|
)
|
|
|
|
# 1. Test dispatch raises TypeError if task_info is not IssueModel for an issue task
|
|
work_item_invalid_issue = WorkItem(
|
|
repo_full_name="meeks/repo1",
|
|
task_type="issue",
|
|
task_number=42,
|
|
task_info=PullRequestModel(number=42), # Invalid model type
|
|
priority=0
|
|
)
|
|
with pytest.raises(TypeError, match="Expected task_info to be an IssueModel"):
|
|
await dispatcher.dispatch("meeks/repo1", [work_item_invalid_issue])
|
|
|
|
# 2. Test _build_pr_mission raises TypeError if task_info is not PullRequestModel
|
|
work_item_invalid_pr = WorkItem(
|
|
repo_full_name="meeks/repo1",
|
|
task_type="pr",
|
|
task_number=42,
|
|
task_info=IssueModel(number=42), # Invalid model type
|
|
priority=0
|
|
)
|
|
with pytest.raises(TypeError, match="Expected task_info to be a PullRequestModel"):
|
|
dispatcher._build_pr_mission(work_item_invalid_pr)
|
|
|
|
# 3. Test _build_issue_mission raises TypeError if task_info is not IssueModel
|
|
with pytest.raises(TypeError, match="Expected task_info to be an IssueModel"):
|
|
dispatcher._build_issue_mission(work_item_invalid_issue)
|
|
|
|
|
|
async def test_dispatch_fails_if_no_authenticated_user() -> None:
|
|
mock_client: MagicMock = MagicMock()
|
|
mock_client.repos = MagicMock()
|
|
mock_tools: MagicMock = MagicMock()
|
|
|
|
# Simulate get_authenticated_user returning None
|
|
mock_client.repos.get_authenticated_user.return_value = None
|
|
|
|
dispatcher = AgentDispatcher(
|
|
client=mock_client,
|
|
issue_tools=MagicMock(),
|
|
pr_tools=MagicMock(),
|
|
file_tools=MagicMock(),
|
|
git_tools=MagicMock(),
|
|
)
|
|
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
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="No authenticated user found."):
|
|
await dispatcher.dispatch("meeks/repo1", [work_item])
|
|
|
|
# Simulate get_authenticated_user raising an Exception
|
|
mock_client.repos.get_authenticated_user.side_effect = Exception("API error")
|
|
with pytest.raises(RuntimeError, match="No authenticated user found."):
|
|
await dispatcher.dispatch("meeks/repo1", [work_item])
|
|
|
|
|
|
|
|
|