refactor: replace Any types with specific types and update bad_code.md

- 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
This commit is contained in:
meeks
2026-07-19 15:35:17 +02:00
parent aa9e8222a3
commit ae4e2d46ac
13 changed files with 326 additions and 169 deletions
+1
View File
@@ -28,3 +28,4 @@ logs/
agent_state.json agent_state.json
ai-electronbun-todo-app/ ai-electronbun-todo-app/
test_connection.py test_connection.py
.aider*
+41 -15
View File
@@ -218,9 +218,9 @@ Removed the `_configure_git_credentials()` method entirely. The agent now relies
## 5. Code Quality Issues ## 5. Code Quality Issues
### 5.1 `Any` Type Overuse ### 5.1 `Any` Type Overuse [RESOLVED]
Throughout the codebase, `Any` is used where specific types would be better: Throughout the codebase, `Any` was used where specific types would be better:
```python ```python
# gitea/client.py:34 # gitea/client.py:34
@@ -231,6 +231,17 @@ def get_authenticated_user(self) -> UserModel | None:
def list_assigned_issues(self) -> list[dict]: # Bare dict, not dict[str, Any] def list_assigned_issues(self) -> list[dict]: # Bare dict, not dict[str, Any]
``` ```
**Resolution:**
Replaced `Any` type annotations with `object` or specific types across the codebase:
- `core/agent.py`: Changed `Any` to `object` for message parameters and model attributes
- `gitea/files_client.py`: Changed `dict[str, Any]` to `dict[str, object]`
- `gitea/issues_client.py`: Changed `dict[str, Any]` to `dict[str, str | list[str]]` for issue data
- `gitea/models.py`: Changed `dict[str, Any]` to `dict[str, object]` for `head` and `base` fields
- `gitea/notifications_client.py`: Changed `list[dict[str, Any]]` to `list[dict[str, object]]`
- `gitea/prs_client.py`: Changed `dict[str, Any]` to specific types (`dict[str, str | None]`, `dict[str, str]`, `dict[str, object]`) and introduced `ReviewRequest` dataclass for review payloads
- `gitea/repos_client.py`: Changed `list[dict[str, Any]]` to `list[dict[str, object]]`
- `gitea/tools/git_tools.py`: Removed unused `from typing import Any` import
### 5.2 `assert` Used for Control Flow [RESOLVED] ### 5.2 `assert` Used for Control Flow [RESOLVED]
**Files:** `core/dispatcher.py:492, 741, 754`, `core/agent.py:74, 94` **Files:** `core/dispatcher.py:492, 741, 754`, `core/agent.py:74, 94`
@@ -399,18 +410,33 @@ Removed the duplicate methods `add_comment` and `add_label` from `IssueTools`. O
## Summary ## Summary
| Category | Severity | Count | | Category | Severity | Total | Unresolved |
| -------------------------- | -------- | ----- | | -------------------------- | -------- | ----- | ---------- |
| Security Vulnerabilities | Critical | 0 | | Security Vulnerabilities | Critical | 4 | 0 |
| Architecture Anti-Patterns | High | 3 | | Architecture Anti-Patterns | High | 5 | 0 |
| Error Handling Problems | High | 0 | | Error Handling Problems | High | 3 | 0 |
| Dangerous Side Effects | High | 0 | | Dangerous Side Effects | High | 3 | 0 |
| Code Quality Issues | Medium | 3 | | Code Quality Issues | Medium | 5 | 1 |
| Performance Issues | Medium | 1 | | Performance Issues | Medium | 2 | 1 |
| Concurrency Issues | Medium | 1 | | Concurrency Issues | Medium | 2 | 1 |
| Prompt Engineering Issues | Medium | 1 | | Prompt Engineering Issues | Medium | 2 | 1 |
| Testing Issues | Medium | 2 | | Testing Issues | Medium | 2 | 2 |
| CUPID Violations | High | 4 | | CUPID Violations | High | 5 | 4 |
**Total: 29 issues identified, 8 unresolved.** **Total: 33 issues identified, 10 unresolved.**
### Unresolved Issues
| # | Issue | Section |
| ---- | -------------------------------------- | ------- |
| 1 | Mutable Default Arguments (Near Miss) | 5.5 |
| 2 | No Caching | 6.2 |
| 3 | No Mutex on Workspace Operations | 7.2 |
| 4 | Massive Embedded System Prompts | 8.1 |
| 5 | Tests Don't Mock HTTP Calls | 9.1 |
| 6 | No Tests for Critical Paths | 9.2 |
| 7 | Not Understandable | 10.2 |
| 8 | Not Performant | 10.3 |
| 9 | Not Inspectable | 10.4 |
| 10 | Not Delightful | 10.5 |
+4 -4
View File
@@ -1,7 +1,7 @@
import asyncio import asyncio
import logging import logging
import lmstudio as lms import lmstudio as lms
from typing import Any, Callable from typing import Callable
from .prompt import CAVEMAN_PROMPT from .prompt import CAVEMAN_PROMPT
logger: logging.Logger = logging.getLogger("agent-base") logger: logging.Logger = logging.getLogger("agent-base")
@@ -13,7 +13,7 @@ class _ActResponseCapture:
def __init__(self) -> None: def __init__(self) -> None:
self.responses: list[str] = [] self.responses: list[str] = []
def __call__(self, message: Any) -> None: def __call__(self, message: object) -> None:
content: str = "" content: str = ""
if hasattr(message, 'content'): if hasattr(message, 'content'):
content = message.content content = message.content
@@ -58,7 +58,7 @@ class BaseAgent:
def __init__(self, model_name: str) -> None: def __init__(self, model_name: str) -> None:
self.model_name: str = model_name self.model_name: str = model_name
self.model: Any | None = None self.model: object | None = None
self.system_prompt: str = "" self.system_prompt: str = ""
async def initialize(self) -> None: async def initialize(self) -> None:
@@ -87,7 +87,7 @@ class BaseAgent:
logger.error(f"Agent execution error: {e}") logger.error(f"Agent execution error: {e}")
return f"Error in agent execution: {str(e)}" return f"Error in agent execution: {str(e)}"
async def run_with_tools(self, user_input: str, tools: list[Any]) -> str: async def run_with_tools(self, user_input: str, tools: list[object]) -> str:
"""Run the agent with tool calling capability.""" """Run the agent with tool calling capability."""
if self.model is None: if self.model is None:
await self.initialize() await self.initialize()
+3 -4
View File
@@ -2,7 +2,6 @@
import base64 import base64
import logging import logging
from typing import Any
import httpx import httpx
@@ -25,7 +24,7 @@ class FilesClient:
def update_file( def update_file(
self, owner: str, repo: str, path: str, message: str, content: str, branch: str self, owner: str, repo: str, path: str, message: str, content: str, branch: str
) -> dict[str, Any]: ) -> dict[str, object]:
"""Update a file in a repository. """Update a file in a repository.
Args: Args:
@@ -79,7 +78,7 @@ class FilesClient:
else "" else ""
) )
def update_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]: def update_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, object]:
"""Update a git reference. """Update a git reference.
Args: Args:
@@ -97,7 +96,7 @@ class FilesClient:
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
def create_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]: def create_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, object]:
"""Create a new git reference. """Create a new git reference.
Args: Args:
+2 -2
View File
@@ -1,7 +1,7 @@
"""Issues client for Gitea API operations.""" """Issues client for Gitea API operations."""
import logging import logging
from typing import Any, Callable, Optional from typing import Callable, Optional
import httpx import httpx
@@ -196,7 +196,7 @@ class IssuesClient:
The created issue. The created issue.
""" """
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues" url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues"
data: dict[str, Any] = {"title": title, "body": body} data: dict[str, str | list[str]] = {"title": title, "body": body}
if labels: if labels:
data["labels"] = labels data["labels"] = labels
if assignees: if assignees:
+3 -3
View File
@@ -1,6 +1,6 @@
"""Pydantic models for Gitea API entities.""" """Pydantic models for Gitea API entities."""
from typing import Optional, Any from typing import Optional
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
@@ -71,8 +71,8 @@ class PullRequestModel(BaseModel):
updated_at: Optional[str] = None updated_at: Optional[str] = None
closed_at: Optional[str] = None closed_at: Optional[str] = None
merged_at: Optional[str] = None merged_at: Optional[str] = None
head: dict[str, Any] = Field(default_factory=dict) head: dict[str, object] = Field(default_factory=dict)
base: dict[str, Any] = Field(default_factory=dict) base: dict[str, object] = Field(default_factory=dict)
repository: Optional[RepositoryModel] = None repository: Optional[RepositoryModel] = None
comments: int = 0 comments: int = 0
comments_url: Optional[str] = None comments_url: Optional[str] = None
+4 -4
View File
@@ -1,7 +1,7 @@
"""Notifications client for Gitea API operations.""" """Notifications client for Gitea API operations."""
import logging import logging
from typing import Any, Optional from typing import Optional
import httpx import httpx
@@ -26,7 +26,7 @@ class NotificationsClient:
def list_unread_notifications( def list_unread_notifications(
self, since: Optional[str] = None self, since: Optional[str] = None
) -> list[dict[str, Any]]: ) -> list[dict[str, object]]:
"""List unread notifications. """List unread notifications.
Args: Args:
@@ -42,9 +42,9 @@ class NotificationsClient:
params["since"] = since params["since"] = since
response = self.client.get(url, params=params) response = self.client.get(url, params=params)
response.raise_for_status() response.raise_for_status()
notifications: list[dict[str, Any]] = response.json() notifications: list[dict[str, object]] = response.json()
result: list[dict[str, Any]] = [] result: list[dict[str, object]] = []
for n in notifications: for n in notifications:
repo_info = n.get("repository") or {} repo_info = n.get("repository") or {}
owner_info = repo_info.get("owner") or {} owner_info = repo_info.get("owner") or {}
+18 -11
View File
@@ -1,7 +1,8 @@
"""Pull Requests client for Gitea API operations.""" """Pull Requests client for Gitea API operations."""
import logging import logging
from typing import Any, Callable from dataclasses import dataclass
from typing import Callable
import httpx import httpx
@@ -17,6 +18,12 @@ from .models import (
logger: logging.Logger = logging.getLogger("gitea.prs_client") logger: logging.Logger = logging.getLogger("gitea.prs_client")
@dataclass
class ReviewRequest:
event: str
body: str
class PullRequestsClient: class PullRequestsClient:
"""HTTP client for Gitea Pull Requests API operations.""" """HTTP client for Gitea Pull Requests API operations."""
@@ -288,7 +295,7 @@ class PullRequestsClient:
""" """
try: try:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}" url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}"
data: dict[str, Any] = {} data: dict[str, str | None] = {}
if title is not None: if title is not None:
data["title"] = title data["title"] = title
if body is not None: if body is not None:
@@ -322,7 +329,7 @@ class PullRequestsClient:
def approve_pr( def approve_pr(
self, owner: str, repo: str, pr_number: int, comment: str self, owner: str, repo: str, pr_number: int, comment: str
) -> dict[str, Any]: ) -> dict[str, object]:
"""Approve a pull request. """Approve a pull request.
Args: Args:
@@ -335,14 +342,14 @@ class PullRequestsClient:
The review response. The review response.
""" """
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews" url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
data: dict[str, Any] = {"event": "APPROVED", "body": comment} review: ReviewRequest = ReviewRequest(event="APPROVED", body=comment)
response = self.client.post(url, json=data) response = self.client.post(url, json={"event": review.event, "body": review.body})
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
def request_changes_pr( def request_changes_pr(
self, owner: str, repo: str, pr_number: int, comment: str self, owner: str, repo: str, pr_number: int, comment: str
) -> dict[str, Any]: ) -> dict[str, object]:
"""Request changes on a pull request. """Request changes on a pull request.
Args: Args:
@@ -355,14 +362,14 @@ class PullRequestsClient:
The review response. The review response.
""" """
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews" url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
data: dict[str, Any] = {"event": "REQUEST_CHANGES", "body": comment} review: ReviewRequest = ReviewRequest(event="REQUEST_CHANGES", body=comment)
response = self.client.post(url, json=data) response = self.client.post(url, json={"event": review.event, "body": review.body})
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
def get_pr_reviews( def get_pr_reviews(
self, owner: str, repo: str, pr_number: int self, owner: str, repo: str, pr_number: int
) -> list[dict[str, Any]]: ) -> list[dict[str, object]]:
"""Get reviews for a pull request. """Get reviews for a pull request.
Args: Args:
@@ -382,7 +389,7 @@ class PullRequestsClient:
def dismiss_review_pr( def dismiss_review_pr(
self, owner: str, repo: str, pr_number: int, review_id: int, message: str self, owner: str, repo: str, pr_number: int, review_id: int, message: str
) -> dict[str, Any]: ) -> dict[str, object]:
"""Dismiss a review on a pull request. """Dismiss a review on a pull request.
Args: Args:
@@ -447,7 +454,7 @@ class PullRequestsClient:
url = ( url = (
f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/merge" f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/merge"
) )
data: dict[str, Any] = { data: dict[str, str] = {
"Do": style, "Do": style,
"MergeTitleField": title, "MergeTitleField": title,
"MergeMessageField": message, "MergeMessageField": message,
+1 -2
View File
@@ -1,7 +1,6 @@
"""Repositories client for Gitea API operations.""" """Repositories client for Gitea API operations."""
import logging import logging
from typing import Any
import httpx import httpx
@@ -36,7 +35,7 @@ class ReposClient:
url = f"{self.base_url}/api/v1/user/repos" url = f"{self.base_url}/api/v1/user/repos"
response = self.client.get(url) response = self.client.get(url)
response.raise_for_status() response.raise_for_status()
repos: list[dict[str, Any]] = response.json() repos: list[dict[str, object]] = response.json()
# Filter to ONLY configured organization repos, include mirrors # Filter to ONLY configured organization repos, include mirrors
seen: set[str] = set() seen: set[str] = set()
result: list[RepositoryModel] = [] result: list[RepositoryModel] = []
-1
View File
@@ -1,4 +1,3 @@
from typing import Any
from gitea.client import GiteaClient from gitea.client import GiteaClient
+27 -12
View File
@@ -9,7 +9,6 @@ from gitea.tools.coding_tools import CodingTools
from core.dispatcher import AgentDispatcher from core.dispatcher import AgentDispatcher
from core.queue import WorkItem from core.queue import WorkItem
from gitea.client import GiteaClient from gitea.client import GiteaClient
from gitea.tools.gitea_tools import GiteaTools
from gitea.models import IssueModel, PullRequestModel from gitea.models import IssueModel, PullRequestModel
pytestmark = pytest.mark.anyio pytestmark = pytest.mark.anyio
@@ -96,12 +95,16 @@ def test_run_verification_failure(tmp_path: Path) -> None:
@patch("core.dispatcher.CodingAgent") @patch("core.dispatcher.CodingAgent")
@patch("core.dispatcher.PlanningAgent") @patch("core.dispatcher.PlanningAgent")
async def test_dispatch_planning_and_coding_phases(mock_planning_class: MagicMock, mock_coding_class: MagicMock) -> None: async def test_dispatch_planning_and_coding_phases(mock_planning_class: MagicMock, mock_coding_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock()
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
# Mock sub-clients
mock_client.prs = MagicMock()
mock_client.issues = MagicMock()
mock_client.notifications = MagicMock()
# Mock no existing PRs # Mock no existing PRs
mock_client.list_repo_pull_requests.return_value = [] mock_client.prs.list_repo_pull_requests.return_value = []
mock_client.get_issue_comments.return_value = [] mock_client.issues.get_issue_comments.return_value = []
from gitea.models import UserModel from gitea.models import UserModel
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai") mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
@@ -110,13 +113,14 @@ async def test_dispatch_planning_and_coding_phases(mock_planning_class: MagicMoc
number=42, number=42,
title="fix bug", title="fix bug",
body="bug details", body="bug details",
user=UserModel(login="unknown-ai") user=UserModel(login="unknown-ai"),
requested_reviewers=[UserModel(login="unknown-ai")],
) )
mock_client.get_pull_request.return_value = mock_pr mock_client.prs.get_pull_request.return_value = mock_pr
mock_client.get_pull_request_diff.return_value = "diff" mock_client.prs.get_pull_request_diff.return_value = "diff"
mock_client.get_pull_request_comments.return_value = [] mock_client.prs.get_pull_request_comments.return_value = []
mock_client.get_pull_request_files.return_value = [] mock_client.prs.get_pull_request_files.return_value = []
mock_client.get_pr_reviews.return_value = [] mock_client.prs.get_pr_reviews.return_value = []
# Mock agent instances # Mock agent instances
mock_planning_agent = MagicMock() mock_planning_agent = MagicMock()
@@ -127,7 +131,18 @@ async def test_dispatch_planning_and_coding_phases(mock_planning_class: MagicMoc
mock_coding_agent.run_with_tools = AsyncMock(return_value="PR #1 Created") mock_coding_agent.run_with_tools = AsyncMock(return_value="PR #1 Created")
mock_coding_class.return_value = mock_coding_agent mock_coding_class.return_value = mock_coding_agent
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) from gitea.tools.issue_tools import IssueTools
from gitea.tools.pr_tools import PRTools
from gitea.tools.file_tools import FileTools
from gitea.tools.git_tools import GitTools
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=IssueTools(mock_client),
pr_tools=PRTools(mock_client),
file_tools=FileTools(mock_client),
git_tools=GitTools(mock_client),
)
work_item = WorkItem( work_item = WorkItem(
repo_full_name="meeks/repo1", repo_full_name="meeks/repo1",
+220 -108
View File
@@ -2,16 +2,19 @@ import pytest
from unittest.mock import MagicMock, AsyncMock, patch, ANY from unittest.mock import MagicMock, AsyncMock, patch, ANY
from core.dispatcher import AgentDispatcher from core.dispatcher import AgentDispatcher
from core.queue import WorkItem 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 from gitea.models import PullRequestModel, IssueModel, CommentModel, UserModel
pytestmark = pytest.mark.anyio pytestmark = pytest.mark.anyio
async def test_dispatch_skips_issue_with_existing_pr() -> None: async def test_dispatch_skips_issue_with_existing_pr() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock()
mock_tools: MagicMock = MagicMock(spec=GiteaTools) 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 # Mock list_repo_pull_requests to return a PR that closes issue #42
pr = PullRequestModel( pr = PullRequestModel(
@@ -19,9 +22,15 @@ async def test_dispatch_skips_issue_with_existing_pr() -> None:
title="fix: resolve bug", title="fix: resolve bug",
body="closes #42" body="closes #42"
) )
mock_client.list_repo_pull_requests.return_value = [pr] mock_client.prs.list_repo_pull_requests.return_value = [pr]
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem( work_item = WorkItem(
repo_full_name="meeks/repo1", repo_full_name="meeks/repo1",
@@ -35,13 +44,14 @@ async def test_dispatch_skips_issue_with_existing_pr() -> None:
assert len(results) == 1 assert len(results) == 1
assert "SKIP: A pull request (PR #101) addressing issue #42 already exists" in results[0] 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") mock_client.prs.list_repo_pull_requests.assert_called_once_with("meeks", "repo1")
@patch("core.dispatcher.CoordinatorAgent") @patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock) -> None: async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock()
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
# Mock list_repo_pull_requests to return PRs that don't address issue #42 # Mock list_repo_pull_requests to return PRs that don't address issue #42
pr = PullRequestModel( pr = PullRequestModel(
@@ -49,8 +59,8 @@ async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock)
title="feat: add something", title="feat: add something",
body="closes #99" body="closes #99"
) )
mock_client.list_repo_pull_requests.return_value = [pr] mock_client.prs.list_repo_pull_requests.return_value = [pr]
mock_client.get_issue_comments.return_value = [] mock_client.issues.get_issue_comments.return_value = []
# Mock CoordinatorAgent invoking propose_plan tool # Mock CoordinatorAgent invoking propose_plan tool
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str: async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
@@ -61,7 +71,13 @@ async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock)
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide) mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
mock_coord_class.return_value = mock_coord_instance mock_coord_class.return_value = mock_coord_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem( work_item = WorkItem(
repo_full_name="meeks/repo1", repo_full_name="meeks/repo1",
@@ -79,8 +95,9 @@ async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock)
@patch("core.dispatcher.CodingAgent") @patch("core.dispatcher.CodingAgent")
async def test_build_pr_mission_injects_issue_context(mock_agent_class: MagicMock) -> None: async def test_build_pr_mission_injects_issue_context(mock_agent_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock()
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
# Mock get_pull_request, get_pull_request_diff, etc. # Mock get_pull_request, get_pull_request_diff, etc.
pr = PullRequestModel( pr = PullRequestModel(
@@ -90,25 +107,31 @@ async def test_build_pr_mission_injects_issue_context(mock_agent_class: MagicMoc
head={"ref": "branch1"}, head={"ref": "branch1"},
base={"ref": "master"} base={"ref": "master"}
) )
mock_client.get_pull_request.return_value = pr mock_client.prs.get_pull_request.return_value = pr
mock_client.get_pull_request_diff.return_value = "diff context" mock_client.prs.get_pull_request_diff.return_value = "diff context"
mock_client.get_pull_request_files.return_value = [] mock_client.prs.get_pull_request_files.return_value = []
mock_client.get_pull_request_comments.return_value = [] mock_client.prs.get_pull_request_comments.return_value = []
# Mock the connected issue and its comments # Mock the connected issue and its comments
issue = IssueModel(number=42, title="bug description") issue = IssueModel(number=42, title="bug description")
mock_client.get_issue.return_value = issue mock_client.issues.get_issue.return_value = issue
comment = CommentModel(id=1, body="First comment") comment = CommentModel(id=1, body="First comment")
mock_client.get_issue_comments.return_value = [comment] mock_client.issues.get_issue_comments.return_value = [comment]
# Mock CodingAgent # Mock CodingAgent
mock_agent_instance = MagicMock() mock_agent_instance = MagicMock()
mock_agent_instance.run_with_tools = AsyncMock(return_value="PR Reviewed.") mock_agent_instance.run_with_tools = AsyncMock(return_value="PR Reviewed.")
mock_agent_class.return_value = mock_agent_instance mock_agent_class.return_value = mock_agent_instance
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai") mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem( work_item = WorkItem(
repo_full_name="meeks/repo1", repo_full_name="meeks/repo1",
@@ -126,46 +149,54 @@ async def test_build_pr_mission_injects_issue_context(mock_agent_class: MagicMoc
assert "bug description" in mission assert "bug description" in mission
assert "First comment" in mission assert "First comment" in mission
mock_client.get_issue.assert_called_once_with("meeks", "repo1", 42) mock_client.issues.get_issue.assert_called_once_with("meeks", "repo1", 42)
mock_client.get_issue_comments.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: async def test_find_pr_for_issue_by_branch() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock()
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
# 1. Matches fix/issue-42-some-desc # 1. Matches fix/issue-42-some-desc
pr1 = PullRequestModel(number=102, head={"ref": "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] mock_client.prs.list_repo_pull_requests.return_value = [pr1]
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
# 2. Matches fix/42 # 2. Matches fix/42
pr2 = PullRequestModel(number=102, head={"ref": "fix/42"}) pr2 = PullRequestModel(number=102, head={"ref": "fix/42"})
mock_client.list_repo_pull_requests.return_value = [pr2] mock_client.prs.list_repo_pull_requests.return_value = [pr2]
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
# 3. Matches fix-42_desc # 3. Matches fix-42_desc
pr3 = PullRequestModel(number=102, head={"ref": "fix-42_desc"}) pr3 = PullRequestModel(number=102, head={"ref": "fix-42_desc"})
mock_client.list_repo_pull_requests.return_value = [pr3] mock_client.prs.list_repo_pull_requests.return_value = [pr3]
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
# 4. Does NOT match fix/142 # 4. Does NOT match fix/142
pr4 = PullRequestModel(number=102, head={"ref": "fix/142"}) pr4 = PullRequestModel(number=102, head={"ref": "fix/142"})
mock_client.list_repo_pull_requests.return_value = [pr4] mock_client.prs.list_repo_pull_requests.return_value = [pr4]
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is None assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is None
# 5. Does NOT match fix/421 # 5. Does NOT match fix/421
pr5 = PullRequestModel(number=102, head={"ref": "fix/421"}) pr5 = PullRequestModel(number=102, head={"ref": "fix/421"})
mock_client.list_repo_pull_requests.return_value = [pr5] mock_client.prs.list_repo_pull_requests.return_value = [pr5]
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is None assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is None
async def test_find_pr_for_issue_by_raw_mention() -> None: async def test_find_pr_for_issue_by_raw_mention() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock()
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
# PR body mentions #42 # PR body mentions #42
pr = PullRequestModel( pr = PullRequestModel(
@@ -174,33 +205,51 @@ async def test_find_pr_for_issue_by_raw_mention() -> None:
body="This is for #42 to fix the bug", body="This is for #42 to fix the bug",
head={"ref": "some-branch"} head={"ref": "some-branch"}
) )
mock_client.list_repo_pull_requests.return_value = [pr] mock_client.prs.list_repo_pull_requests.return_value = [pr]
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) 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) res = dispatcher._find_pr_for_issue("meeks/repo1", 42)
assert res is not None assert res is not None
assert res.number == 103 assert res.number == 103
async def test_dispatch_skips_already_reviewed_pr() -> None: async def test_dispatch_skips_already_reviewed_pr() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock()
mock_tools: MagicMock = MagicMock(spec=GiteaTools) 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 from gitea.models import UserModel
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai") mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
pr = PullRequestModel( pr = PullRequestModel(
number=104, number=104,
title="already reviewed PR", title="already reviewed PR",
body="closes #42", body="closes #42",
user=UserModel(login="unknown-ai") user=UserModel(login="unknown-ai"),
requested_reviewers=[],
) )
mock_client.get_pull_request.return_value = pr mock_client.prs.get_pull_request.return_value = pr
mock_client.get_pull_request_diff.return_value = "diff" mock_client.prs.get_pull_request_diff.return_value = "diff"
mock_client.get_pull_request_comments.return_value = [ mock_client.prs.get_pull_request_comments.return_value = [
CommentModel(id=1, body="Reviewed by AI Agent: Looks good.") CommentModel(id=1, body="Reviewed by AI Agent: Looks good.")
] ]
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem( work_item = WorkItem(
repo_full_name="meeks/repo1", repo_full_name="meeks/repo1",
@@ -225,7 +274,7 @@ def _make_comment(login: str, body: str) -> CommentModel:
def _make_dispatcher_for_reply_tests() -> AgentDispatcher: def _make_dispatcher_for_reply_tests() -> AgentDispatcher:
mock_client = MagicMock() mock_client = MagicMock()
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai") mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
return AgentDispatcher(client=mock_client, tools=MagicMock()) return AgentDispatcher(client=mock_client, tools=MagicMock())
@@ -266,12 +315,13 @@ def test_is_awaiting_reply_no_marker_not_detected() -> None:
@patch("core.dispatcher.CoordinatorAgent") @patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_proposes_plan(mock_coord_class: MagicMock) -> None: async def test_dispatch_proposes_plan(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock()
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
mock_client.list_repo_pull_requests.return_value = [] mock_client.prs.list_repo_pull_requests.return_value = []
mock_client.get_issue_comments.return_value = [] mock_client.issues.get_issue_comments.return_value = []
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai") mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str: 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) coord_tools.propose_plan(plan="- Add endpoint\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->", issue_number=42)
@@ -280,7 +330,13 @@ async def test_dispatch_proposes_plan(mock_coord_class: MagicMock) -> None:
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide) mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
mock_coord_class.return_value = mock_coord_instance mock_coord_class.return_value = mock_coord_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem( work_item = WorkItem(
repo_full_name="meeks/repo1", repo_full_name="meeks/repo1",
task_type="issue", task_type="issue",
@@ -292,17 +348,18 @@ async def test_dispatch_proposes_plan(mock_coord_class: MagicMock) -> None:
results = await dispatcher.dispatch("meeks/repo1", [work_item]) results = await dispatcher.dispatch("meeks/repo1", [work_item])
assert len(results) == 1 assert len(results) == 1
assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0] 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 -->") 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") @patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_answers_question(mock_coord_class: MagicMock) -> None: async def test_dispatch_answers_question(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock()
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
mock_client.list_repo_pull_requests.return_value = [] mock_client.prs.list_repo_pull_requests.return_value = []
mock_client.get_issue_comments.return_value = [] mock_client.issues.get_issue_comments.return_value = []
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai") mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str: 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) coord_tools.answer_question(answer="X works by doing Y.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->", issue_number=42)
@@ -311,7 +368,13 @@ async def test_dispatch_answers_question(mock_coord_class: MagicMock) -> None:
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide) mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
mock_coord_class.return_value = mock_coord_instance mock_coord_class.return_value = mock_coord_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem( work_item = WorkItem(
repo_full_name="meeks/repo1", repo_full_name="meeks/repo1",
task_type="issue", task_type="issue",
@@ -323,19 +386,20 @@ async def test_dispatch_answers_question(mock_coord_class: MagicMock) -> None:
results = await dispatcher.dispatch("meeks/repo1", [work_item]) results = await dispatcher.dispatch("meeks/repo1", [work_item])
assert len(results) == 1 assert len(results) == 1
assert "POSTED_COMMENT: ANSWER_QUESTION" in results[0] 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 -->") 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") @patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_closes_issue_on_satisfaction(mock_coord_class: MagicMock) -> None: async def test_dispatch_closes_issue_on_satisfaction(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock()
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
mock_client.list_repo_pull_requests.return_value = [] mock_client.prs.list_repo_pull_requests.return_value = []
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai") mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
# Human comments indicating satisfaction after our answer # Human comments indicating satisfaction after our answer
mock_client.get_issue_comments.return_value = [ 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("unknown-ai", "Here is the answer.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->"),
_make_comment("michael", "Yes, thanks! That makes sense.") _make_comment("michael", "Yes, thanks! That makes sense.")
] ]
@@ -347,7 +411,13 @@ async def test_dispatch_closes_issue_on_satisfaction(mock_coord_class: MagicMock
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide) mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
mock_coord_class.return_value = mock_coord_instance mock_coord_class.return_value = mock_coord_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem( work_item = WorkItem(
repo_full_name="meeks/repo1", repo_full_name="meeks/repo1",
task_type="issue", task_type="issue",
@@ -359,27 +429,28 @@ async def test_dispatch_closes_issue_on_satisfaction(mock_coord_class: MagicMock
results = await dispatcher.dispatch("meeks/repo1", [work_item]) results = await dispatcher.dispatch("meeks/repo1", [work_item])
assert len(results) == 1 assert len(results) == 1
assert "CLOSED_ISSUE: Issue #42 closed." in results[0] 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.issues.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) mock_client.issues.close_issue.assert_called_once_with("meeks", "repo1", 42)
@patch("subprocess.run") @patch("subprocess.run")
@patch("core.dispatcher.CodingAgent") @patch("core.dispatcher.CodingAgent")
@patch("core.dispatcher.CoordinatorAgent") @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: 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_client: MagicMock = MagicMock()
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
mock_client.list_repo_pull_requests.return_value = [] mock_client.prs.list_repo_pull_requests.return_value = []
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai") mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
mock_client.get_issue_comments.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("unknown-ai", "### Proposed Plan\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
_make_comment("michael", "looks good, go ahead") _make_comment("michael", "looks good, go ahead")
] ]
# Return PR object on creation # 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_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_client.prs.create_pull_request.return_value = mock_pr
# Mock planning agent deciding EXECUTE_PLAN # Mock planning agent deciding EXECUTE_PLAN
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str: async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
@@ -390,7 +461,13 @@ async def test_dispatch_executes_approved_plan_and_creates_wip_pr(mock_coord_cla
# Mock coding agent executing plan # Mock coding agent executing plan
mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Completed Successfully.") mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Completed Successfully.")
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem( work_item = WorkItem(
repo_full_name="meeks/repo1", repo_full_name="meeks/repo1",
task_type="issue", task_type="issue",
@@ -408,20 +485,21 @@ async def test_dispatch_executes_approved_plan_and_creates_wip_pr(mock_coord_cla
mock_run.assert_any_call(["git", "checkout", "-b", "fix/issue-42-add-x"], 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 # Verify WIP PR creation and starting comment
mock_client.create_pull_request.assert_called_once_with( 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." "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).") mock_client.issues.add_comment.assert_any_call("meeks", "repo1", 42, "Started work on PR #105 (http://gitea/pr/105).")
@patch("subprocess.run") @patch("subprocess.run")
@patch("core.dispatcher.CodingAgent") @patch("core.dispatcher.CodingAgent")
@patch("core.dispatcher.CoordinatorAgent") @patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_resumes_wip_pr(mock_coord_class: MagicMock, mock_coding_class: MagicMock, mock_run: MagicMock) -> None: 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_client: MagicMock = MagicMock()
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai") mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
# Existing WIP PR addressing issue #42 # Existing WIP PR addressing issue #42
wip_pr = PullRequestModel( wip_pr = PullRequestModel(
@@ -430,14 +508,14 @@ async def test_dispatch_resumes_wip_pr(mock_coord_class: MagicMock, mock_coding_
state="open", state="open",
head={"ref": "fix/issue-42-add-x"} head={"ref": "fix/issue-42-add-x"}
) )
mock_client.list_repo_pull_requests.return_value = [wip_pr] mock_client.prs.list_repo_pull_requests.return_value = [wip_pr]
mock_client.get_pr_reviews.return_value = [] mock_client.prs.get_pr_reviews.return_value = []
mock_client.get_issue_comments.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("unknown-ai", "### Proposed Plan\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
_make_comment("michael", "looks good, go ahead") _make_comment("michael", "looks good, go ahead")
] ]
mock_client.get_pull_request_comments.return_value = [] mock_client.prs.get_pull_request_comments.return_value = []
# Mock planning agent deciding EXECUTE_PLAN # Mock planning agent deciding EXECUTE_PLAN
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str: async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
@@ -448,7 +526,13 @@ async def test_dispatch_resumes_wip_pr(mock_coord_class: MagicMock, mock_coding_
# Mock coding agent executing plan # Mock coding agent executing plan
mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Updated Successfully.") mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Updated Successfully.")
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem( work_item = WorkItem(
repo_full_name="meeks/repo1", repo_full_name="meeks/repo1",
task_type="issue", task_type="issue",
@@ -462,14 +546,15 @@ async def test_dispatch_resumes_wip_pr(mock_coord_class: MagicMock, mock_coding_
assert results[0] == "PR Updated Successfully." assert results[0] == "PR Updated Successfully."
# Ensure create_pull_request was NOT called since it already exists # Ensure create_pull_request was NOT called since it already exists
mock_client.create_pull_request.assert_not_called() mock_client.prs.create_pull_request.assert_not_called()
async def test_dispatch_skips_pr_if_not_requested_reviewer() -> None: async def test_dispatch_skips_pr_if_not_requested_reviewer() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock()
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai") mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
# PR authored by michael, requested reviewers is empty (agent not requested) # PR authored by michael, requested reviewers is empty (agent not requested)
pr_detail = PullRequestModel( pr_detail = PullRequestModel(
@@ -479,9 +564,15 @@ async def test_dispatch_skips_pr_if_not_requested_reviewer() -> None:
user=UserModel(login="michael"), user=UserModel(login="michael"),
requested_reviewers=[] requested_reviewers=[]
) )
mock_client.get_pull_request.return_value = pr_detail mock_client.prs.get_pull_request.return_value = pr_detail
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem( work_item = WorkItem(
repo_full_name="meeks/repo1", repo_full_name="meeks/repo1",
task_type="pr", task_type="pr",
@@ -513,12 +604,13 @@ def test_coordinator_tools_registration() -> None:
@patch("core.dispatcher.CoordinatorAgent") @patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMock) -> None: async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock()
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
mock_client.list_repo_pull_requests.return_value = [] mock_client.prs.list_repo_pull_requests.return_value = []
mock_client.get_issue_comments.return_value = [] mock_client.issues.get_issue_comments.return_value = []
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai") mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
# Mock agent invoking propose_plan tool # Mock agent invoking propose_plan tool
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str: async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
@@ -529,7 +621,13 @@ async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMoc
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide) mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
mock_coord_class.return_value = mock_coord_instance mock_coord_class.return_value = mock_coord_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem( work_item = WorkItem(
repo_full_name="meeks/repo1", repo_full_name="meeks/repo1",
task_type="issue", task_type="issue",
@@ -541,7 +639,7 @@ async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMoc
results = await dispatcher.dispatch("meeks/repo1", [work_item]) results = await dispatcher.dispatch("meeks/repo1", [work_item])
assert len(results) == 1 assert len(results) == 1
assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0] assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0]
mock_client.add_comment.assert_called_once_with( mock_client.issues.add_comment.assert_called_once_with(
"meeks", "meeks",
"repo1", "repo1",
42, 42,
@@ -550,15 +648,22 @@ async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMoc
async def test_dispatcher_raises_type_error_for_invalid_task_info() -> None: async def test_dispatcher_raises_type_error_for_invalid_task_info() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock()
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
# Mock return values for methods called prior to the isinstance check # Mock return values for methods called prior to the isinstance check
mock_client.list_repo_pull_requests.return_value = [] mock_client.prs.list_repo_pull_requests.return_value = []
mock_client.get_issue_comments.return_value = [] mock_client.issues.get_issue_comments.return_value = []
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai") mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) 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 # 1. Test dispatch raises TypeError if task_info is not IssueModel for an issue task
work_item_invalid_issue = WorkItem( work_item_invalid_issue = WorkItem(
@@ -588,13 +693,20 @@ async def test_dispatcher_raises_type_error_for_invalid_task_info() -> None:
async def test_dispatch_fails_if_no_authenticated_user() -> None: async def test_dispatch_fails_if_no_authenticated_user() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock()
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
# Simulate get_authenticated_user returning None # Simulate get_authenticated_user returning None
mock_client.get_authenticated_user.return_value = None mock_client.repos.get_authenticated_user.return_value = None
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem( work_item = WorkItem(
repo_full_name="meeks/repo1", repo_full_name="meeks/repo1",
task_type="issue", task_type="issue",
@@ -607,7 +719,7 @@ async def test_dispatch_fails_if_no_authenticated_user() -> None:
await dispatcher.dispatch("meeks/repo1", [work_item]) await dispatcher.dispatch("meeks/repo1", [work_item])
# Simulate get_authenticated_user raising an Exception # Simulate get_authenticated_user raising an Exception
mock_client.get_authenticated_user.side_effect = Exception("API error") mock_client.repos.get_authenticated_user.side_effect = Exception("API error")
with pytest.raises(RuntimeError, match="No authenticated user found."): with pytest.raises(RuntimeError, match="No authenticated user found."):
await dispatcher.dispatch("meeks/repo1", [work_item]) await dispatcher.dispatch("meeks/repo1", [work_item])
+2 -3
View File
@@ -5,7 +5,6 @@ from unittest.mock import MagicMock, AsyncMock, patch
from core.orchestrator import AgentOrchestrator from core.orchestrator import AgentOrchestrator
from gitea.client import GiteaClient from gitea.client import GiteaClient
from gitea.tools.gitea_tools import GiteaTools
from gitea.models import IssueModel, PullRequestModel, RepositoryModel from gitea.models import IssueModel, PullRequestModel, RepositoryModel
pytestmark = pytest.mark.anyio pytestmark = pytest.mark.anyio
@@ -31,7 +30,7 @@ async def test_poll_and_dispatch_no_notifications(
) -> None: ) -> None:
mock_get_path.return_value = temp_state_file mock_get_path.return_value = temp_state_file
mock_client = MagicMock(spec=GiteaClient) mock_client = MagicMock(spec=GiteaClient)
mock_tools = MagicMock(spec=GiteaTools) mock_tools = MagicMock()
# Return no notifications # Return no notifications
mock_client.list_unread_notifications.return_value = [] mock_client.list_unread_notifications.return_value = []
@@ -68,7 +67,7 @@ async def test_poll_and_dispatch_with_notifications(
mock_reader.decide_notification = AsyncMock(side_effect=mock_decide_notification) mock_reader.decide_notification = AsyncMock(side_effect=mock_decide_notification)
mock_notification_reader_class.return_value = mock_reader mock_notification_reader_class.return_value = mock_reader
mock_client = MagicMock(spec=GiteaClient) mock_client = MagicMock(spec=GiteaClient)
mock_tools = MagicMock(spec=GiteaTools) mock_tools = MagicMock()
# Set up mock Gitea notifications # Set up mock Gitea notifications
notifications = [ notifications = [