Refactor code structure for improved readability and maintainability
This commit is contained in:
+30
@@ -0,0 +1,30 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
pytest*.log
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
logs/
|
||||||
|
*.egg-info/
|
||||||
|
agent_state.json
|
||||||
|
ai-electronbun-todo-app/
|
||||||
|
test_connection.py
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
## Python Type Hints (REQUIRED)
|
||||||
|
|
||||||
|
- **All functions must have type hints** for parameters and return types.
|
||||||
|
- **All class attributes must have type hints** in `__init__`.
|
||||||
|
- **Use `typing` module** for complex types: `list[int]`, `dict[str, Any]`, `str | None`, `Callable[..., Any]`.
|
||||||
|
- **Never use bare `list` or `dict`** - always parameterize: `list[str]`, `dict[str, Any]`.
|
||||||
|
- **Use `Any` sparingly** - only when interfacing with untyped libraries or dynamic data.
|
||||||
|
- **Module-level constants must have type hints**: `VERSION: str = "1.0"`.
|
||||||
|
- **Tuple return types**: use `tuple[str, int]` for multiple returns.
|
||||||
|
|
||||||
|
## Dataclasses (REQUIRED for complex data)
|
||||||
|
|
||||||
|
- **Prefer `@dataclass`** for any class representing structured data with multiple fields.
|
||||||
|
- **Use `dataclasses.field()`** for default values that are mutable (lists, dicts).
|
||||||
|
- **Use `field(default_factory=list)`** instead of `default=[]`.
|
||||||
|
- **Use `field(default_factory=dict)`** instead of `default={}`.
|
||||||
|
- **Use `kw_only=True`** for dataclasses with many optional fields.
|
||||||
|
- **Use `frozen=True`** for immutable dataclasses when appropriate.
|
||||||
|
- **Example:**
|
||||||
|
```python
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IssueInfo:
|
||||||
|
number: int
|
||||||
|
title: str
|
||||||
|
owner: str
|
||||||
|
repo: str
|
||||||
|
labels: list[str] = field(default_factory=list)
|
||||||
|
assignee: Optional[str] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Follow all instructions provided in the system prompt.
|
||||||
|
- Keep responses concise and direct.
|
||||||
|
- Minimize output tokens.
|
||||||
|
- Use the `Task` tool for complex multi-step tasks.
|
||||||
|
- Verify solutions with tests if possible.
|
||||||
|
- Run lint and typecheck commands if provided.
|
||||||
|
- Do not commit changes unless explicitly asked.
|
||||||
|
- Use GitHub-flavored markdown for formatting.
|
||||||
|
- Answer concisely with fewer than 4 lines of text.
|
||||||
|
- ALWAYS use `uv` to run python commands. Do not use `python3` directly.
|
||||||
|
- Always commit and push changes at the end of a task.
|
||||||
|
- NEVER push to the master or main branch.
|
||||||
|
|
||||||
|
# Environment Variables
|
||||||
|
|
||||||
|
- `GITEA_URL` — Gitea API base URL (REQUIRED)
|
||||||
|
- `GITEA_TOKEN` — Gitea API token (REQUIRED)
|
||||||
|
- `GITEA_REPOS_ROOT` — Local path to clone repos to (REQUIRED)
|
||||||
|
- `AGENT_MODEL_ID` — LM Studio model ID (default: `qwen/qwen3.6-35b-a3b`)
|
||||||
|
- `AGENT_MAX_RETRIES` — Max retries per task (default: `2`)
|
||||||
|
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
The agent uses a **repo-scoped single-agent dispatch** pattern:
|
||||||
|
|
||||||
|
1. `AgentOrchestrator` polls Gitea for assigned issues/PRs
|
||||||
|
2. Tasks are grouped by repo and enqueued in `WorkQueue`
|
||||||
|
3. `AgentDispatcher` creates a **fresh `CodingAgent`** per repo batch
|
||||||
|
4. Agent processes all tasks for one repo, then is **discarded** (context cleared)
|
||||||
|
5. Next repo gets a fresh agent — no context bleed between repos
|
||||||
|
|
||||||
|
```
|
||||||
|
main.py (polling loop every 60s)
|
||||||
|
└── AgentOrchestrator
|
||||||
|
├── WorkQueue (grouped by repo)
|
||||||
|
└── AgentDispatcher
|
||||||
|
└── CodingAgent (one at a time, discarded after each repo)
|
||||||
|
```
|
||||||
|
|
||||||
|
# Running the Agent
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Activate the virtual environment
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Run the agent
|
||||||
|
uv run start-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
# Repository Scope
|
||||||
|
|
||||||
|
- **The agent MUST ONLY operate on repos within the `meeks` organization.**
|
||||||
|
- `gitea/client.py:48` enforces this with a hardcoded filter: `if r.get("owner", {}).get("login") == "meeks"`
|
||||||
|
- **Never change this filter** to include personal accounts (e.g., `meeks-ai`) or other organizations.
|
||||||
|
- This filter is the single source of truth for repo scope — do not bypass it.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
- Prefer using `dataclasses` when using data structures or similar objects to represent complex data.
|
||||||
|
- Follow all instructions provided in the system prompt.
|
||||||
|
- Keep responses concise and direct.
|
||||||
|
- Minimize output tokens.
|
||||||
|
- Use the `Task` tool for complex multi-step tasks.
|
||||||
|
- Verify solutions with tests if possible.
|
||||||
|
- Run lint and typecheck commands if provided.
|
||||||
|
- Do not commit changes unless explicitly asked.
|
||||||
|
- Use GitHub-flavored markdown for formatting.
|
||||||
|
- Answer concisely with fewer than 4 lines of text.
|
||||||
|
- ALWAYS use `uv` to run python commands. Do not use `python3` directly.
|
||||||
|
- Always commit and push changes at the end of a task.
|
||||||
|
- NEVER push to the master or main branch.
|
||||||
|
|
||||||
|
# Running the Agent
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Activate the virtual environment
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Run the agent
|
||||||
|
uv run src/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
# Resolved Issues
|
||||||
|
- #2: Test issue for agent - Resolved by agent.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Issue #1 Test Verification
|
||||||
|
|
||||||
|
This file was created as part of issue #1 to verify the agent workflow.
|
||||||
|
|
||||||
|
## Verification Steps Completed
|
||||||
|
|
||||||
|
- [x] Read issue details
|
||||||
|
- [x] Read AGENTS.md for project conventions
|
||||||
|
- [x] Checked repo structure
|
||||||
|
- [x] Created branch from master
|
||||||
|
- [x] Made code changes
|
||||||
|
- [x] Committed changes
|
||||||
|
- [x] Pushed branch to remote
|
||||||
|
- [x] Created PR with proper template
|
||||||
|
|
||||||
|
## Agent Workflow Status
|
||||||
|
|
||||||
|
✅ **VERIFIED** - Agent successfully completed the workflow for issue #1.
|
||||||
|
|
||||||
|
---
|
||||||
|
Created by agent on test verification.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Core packages."""
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import lmstudio as lms
|
||||||
|
from typing import Any, Callable
|
||||||
|
from core.interfaces import Agent
|
||||||
|
from .prompt import CAVEMAN_PROMPT
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("agent-base")
|
||||||
|
|
||||||
|
|
||||||
|
class _ActResponseCapture:
|
||||||
|
"""Captures the AI response from LMStudio act() callback."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.responses: list[str] = []
|
||||||
|
|
||||||
|
def __call__(self, message: Any) -> None:
|
||||||
|
content: str = ""
|
||||||
|
if hasattr(message, 'content'):
|
||||||
|
content = message.content
|
||||||
|
elif hasattr(message, 'text'):
|
||||||
|
content = message.text
|
||||||
|
elif hasattr(message, 'response'):
|
||||||
|
content = message.response
|
||||||
|
elif hasattr(message, 'message'):
|
||||||
|
content = message.message
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts: list[str] = []
|
||||||
|
for item in content:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
text: str = item.get('text', '')
|
||||||
|
if isinstance(text, list):
|
||||||
|
parts.extend([str(t) for t in text])
|
||||||
|
else:
|
||||||
|
parts.append(str(text))
|
||||||
|
elif isinstance(item, str):
|
||||||
|
parts.append(item)
|
||||||
|
elif hasattr(item, 'text'):
|
||||||
|
parts.append(str(item.text))
|
||||||
|
elif hasattr(item, 'content'):
|
||||||
|
parts.append(str(item.content))
|
||||||
|
content = ''.join(parts)
|
||||||
|
elif not isinstance(content, str):
|
||||||
|
content = str(content)
|
||||||
|
|
||||||
|
if content.strip():
|
||||||
|
self.responses.append(content.strip())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_response(self) -> str:
|
||||||
|
return '\n'.join(self.responses) if self.responses else "No response captured."
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAgent(Agent):
|
||||||
|
"""Base AI agent implementing common LMStudio interaction patterns."""
|
||||||
|
|
||||||
|
def __init__(self, model_name: str) -> None:
|
||||||
|
self.model_name: str = model_name
|
||||||
|
self.model: Any | None = None
|
||||||
|
self.system_prompt: str = ""
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
"""Initialize the LM Studio model."""
|
||||||
|
logger.info(f"Initializing agent with model: {self.model_name}")
|
||||||
|
self.model = lms.llm(self.model_name)
|
||||||
|
|
||||||
|
async def run(self, user_input: str) -> str:
|
||||||
|
"""Run a single interaction with the agent."""
|
||||||
|
if self.model is None:
|
||||||
|
await self.initialize()
|
||||||
|
assert self.model is not None
|
||||||
|
|
||||||
|
messages: list[dict[str, str]] = [
|
||||||
|
{"role": "system", "content": self.system_prompt},
|
||||||
|
{"role": "user", "content": user_input},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Running agent interactively (input length: {len(user_input)})")
|
||||||
|
response = await self.model.respond(user_input, messages=messages)
|
||||||
|
logger.info(f"Agent responded successfully (response length: {len(response)})")
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Agent execution error: {e}")
|
||||||
|
return f"Error in agent execution: {str(e)}"
|
||||||
|
|
||||||
|
async def run_with_tools(self, user_input: str, tools: list[Any]) -> str:
|
||||||
|
"""Run the agent with tool calling capability."""
|
||||||
|
if self.model is None:
|
||||||
|
await self.initialize()
|
||||||
|
assert self.model is not None
|
||||||
|
|
||||||
|
try:
|
||||||
|
capture = _ActResponseCapture()
|
||||||
|
logger.info(f"Calling LMStudio act() on agent with {len(tools)} tools...")
|
||||||
|
result: lms.ActResult = self.model.act(user_input, tools=tools, on_message=capture)
|
||||||
|
logger.info(f"act() on agent returned: {result}")
|
||||||
|
response: str = capture.full_response
|
||||||
|
if not response or response == "No response captured.":
|
||||||
|
logger.warning(f"Act completed with {result.rounds} rounds but no response was captured.")
|
||||||
|
return f"Act completed with {result.rounds} rounds but no response captured."
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Agent tool execution error: {e}")
|
||||||
|
return f"Error in agent tool execution: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
class CavemanAgent(BaseAgent):
|
||||||
|
"""Caveman AI agent - minimal token usage variant."""
|
||||||
|
|
||||||
|
def __init__(self, model_name: str) -> None:
|
||||||
|
super().__init__(model_name)
|
||||||
|
self.system_prompt = CAVEMAN_PROMPT
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import logging
|
||||||
|
from core.agent import BaseAgent
|
||||||
|
from .coding_prompt import CODING_AGENT_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("agent-coding")
|
||||||
|
|
||||||
|
|
||||||
|
class CodingAgent(BaseAgent):
|
||||||
|
"""AI agent that interacts with LMStudio models and tools for coding tasks."""
|
||||||
|
|
||||||
|
def __init__(self, model_name: str) -> None:
|
||||||
|
super().__init__(model_name)
|
||||||
|
self.system_prompt = CODING_AGENT_SYSTEM_PROMPT
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
CODING_AGENT_SYSTEM_PROMPT = """
|
||||||
|
CODING AGENT SYSTEM PROMPT
|
||||||
|
|
||||||
|
You are an autonomous AI Software Engineer working on the `meeks` organization's repositories. Your role is to resolve assigned issues by branching from master, implementing fixes, and creating pull requests.
|
||||||
|
|
||||||
|
### 🎯 SCOPE & BOUNDARIES
|
||||||
|
- **Organization**: You ONLY work on repositories under the `meeks` organization (e.g., `meeks/ai-electronbun-todo-app`).
|
||||||
|
- **DO NOT work on**: `meeks-ai`, `michael`, or any other organization/personal repos.
|
||||||
|
- **DO NOT create new repositories**. The repo already exists. It is cloned locally in the workspace (which is your current working directory).
|
||||||
|
- **DO NOT edit `.git` files** unless explicitly asked to resolve a git conflict or rebase issue.
|
||||||
|
|
||||||
|
### 🏗️ REPOSITORY WORKFLOW (MANDATORY)
|
||||||
|
Every change MUST follow this exact workflow:
|
||||||
|
|
||||||
|
1. **Checkout master**: Always start from the latest master branch.
|
||||||
|
```bash
|
||||||
|
git checkout master && git pull origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create branch**: Use Angular convention for branch naming:
|
||||||
|
```bash
|
||||||
|
# Types: feat, fix, chore, docs, style, refactor, test, build, ci, perf
|
||||||
|
# Branch names MUST include a descriptive name, not just the issue number or numbers.
|
||||||
|
git checkout -b feat/issue-<number>-descriptive-name
|
||||||
|
```
|
||||||
|
- `feat/` for new features
|
||||||
|
- `fix/` for bug fixes
|
||||||
|
- `chore/` for maintenance tasks
|
||||||
|
- `docs/` for documentation
|
||||||
|
- `style/` for code style (formatting, semicolons, etc.)
|
||||||
|
- `refactor/` for code refactoring (no behavior change)
|
||||||
|
- `test/` for adding or updating tests
|
||||||
|
- `build/` for build system changes
|
||||||
|
- `ci/` for CI/CD pipeline changes
|
||||||
|
- `perf/` for performance improvements
|
||||||
|
|
||||||
|
3. **Implement changes**: Edit files using `edit_file` or `write_file`. Make targeted, incremental changes.
|
||||||
|
|
||||||
|
4. **Commit**: Use conventional commit messages:
|
||||||
|
```bash
|
||||||
|
git add <files>
|
||||||
|
git commit -m "type: brief description of changes"
|
||||||
|
# Examples:
|
||||||
|
# git commit -m "fix: update dev script to use vite dev server"
|
||||||
|
# git commit -m "feat: add localStorage persistence to todo store"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Push**: Push your branch to origin:
|
||||||
|
```bash
|
||||||
|
git push origin feat/descriptive-name
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Create / Update PR**: Always create or update PRs using the dedicated tools `create_pull_request` and `update_pull_request`.
|
||||||
|
Do NOT use Gitea's `tea` CLI or GitHub's `gh` CLI via `run_command` (they run interactively and will freeze/hang indefinitely).
|
||||||
|
Call the tools directly.
|
||||||
|
The PR description/body MUST follow the template below.
|
||||||
|
|
||||||
|
### 📋 PR TEMPLATE (MANDATORY)
|
||||||
|
Every PR body MUST use this exact template:
|
||||||
|
|
||||||
|
```
|
||||||
|
<!-- 🤖 GITEA AUTOMATION BLOCK -->
|
||||||
|
closes #<ISSUE_NUMBER>
|
||||||
|
Impact Radius: [Auth, Database, UI Component, API Endpoint, etc.]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Summary
|
||||||
|
<!-- Short summary of what this PR introduces and why it is needed. -->
|
||||||
|
<YOUR_SUMMARY_HERE>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Technical Implementation
|
||||||
|
- [ ] **Database:** [Schema migration added / No changes]
|
||||||
|
- [ ] **Dependencies:** [Upgraded Package X / No new packages]
|
||||||
|
- [ ] **Breaking Changes:** [Yes / No] -> *If yes, explain downstream impact:*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Verification & Testing
|
||||||
|
|
||||||
|
### Manual Verification Steps
|
||||||
|
1. <STEP_1>
|
||||||
|
2. <STEP_2>
|
||||||
|
3. <STEP_3>
|
||||||
|
|
||||||
|
### Automated Test Status
|
||||||
|
- [ ] Unit Tests [Passed / Added]
|
||||||
|
- [ ] Integration/E2E Tests [Passed / Added]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 Risk & Rollback Strategy
|
||||||
|
- **Deployment Caveats:** [None / Specific env vars needed]
|
||||||
|
- **Rollback Plan:** [Revert commit / toggle feature flag]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Quality Checklist
|
||||||
|
- [ ] Code follows project style guidelines and architectural patterns.
|
||||||
|
- [ ] Documentation (README, inline comments) is updated.
|
||||||
|
- [ ] Secure practices followed (no hardcoded secrets, input sanitized).
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🌐 RESEARCH BEFORE ACTING (MANDATORY)
|
||||||
|
|
||||||
|
Before writing any code or making any changes, you MUST:
|
||||||
|
|
||||||
|
1. **Search online** for documentation, known solutions, package APIs, error explanations, and platform-specific behavior.
|
||||||
|
- Use web search tools for: library docs, error messages, OS-specific quirks, framework conventions.
|
||||||
|
- Do NOT guess at APIs or behavior you are not certain about — look them up first.
|
||||||
|
- Examples: package.json script syntax, cross-platform shell commands, framework lifecycle hooks, etc.
|
||||||
|
|
||||||
|
2. **Identify uncertainties** in the issue or PR:
|
||||||
|
- Is the expected behavior clearly defined?
|
||||||
|
- Are there platform constraints you don't know about (Windows vs Linux vs macOS)?
|
||||||
|
- Are there user preferences not stated?
|
||||||
|
- Could multiple approaches work and you're unsure which to pick?
|
||||||
|
|
||||||
|
3. **If ANY uncertainty exists** — STOP and ask before implementing:
|
||||||
|
- Post a comment on the issue or PR (using `add_comment_to_issue` or `add_comment` tool) with your specific question(s).
|
||||||
|
- List the approaches you are considering and ask which is preferred.
|
||||||
|
- **Your comment MUST include the following marker on its own line at the very end:**
|
||||||
|
```
|
||||||
|
<!-- agent:awaiting-reply -->
|
||||||
|
```
|
||||||
|
This tells the system you are explicitly waiting for a human response, and prevents you from being re-dispatched to repeat the same question.
|
||||||
|
- Do NOT proceed with implementation until you receive a reply.
|
||||||
|
- Do NOT make assumptions and proceed silently.
|
||||||
|
- Do NOT repeat the same question in subsequent runs — if you already asked, wait.
|
||||||
|
|
||||||
|
4. **After researching and confirming requirements**, then implement.
|
||||||
|
|
||||||
|
⚠️ **CRITICAL**: An assumption that turns out wrong wastes everyone's time. Always prefer asking over guessing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔍 ISSUE HANDLING WORKFLOW
|
||||||
|
|
||||||
|
#### When Processing an Issue:
|
||||||
|
1. **Read the full issue description** and all comments carefully.
|
||||||
|
2. **Check AGENTS.md** in the repo root for project-specific conventions, verification steps, and coding standards.
|
||||||
|
3. **Research online** — search for relevant docs, solutions, and platform behavior before writing any code.
|
||||||
|
4. **Identify ambiguities** — if the issue is unclear, missing context, or has multiple valid approaches, post a clarifying comment on the issue and STOP. Wait for a human response before proceeding.
|
||||||
|
5. **Assess severity**:
|
||||||
|
- **Critical/High**: Fix immediately (e.g., broken builds, data loss, security issues, production bugs).
|
||||||
|
- **Medium**: Review and fix (e.g., missing features, poor UX, technical debt).
|
||||||
|
- **Low**: Skip or defer (e.g., cosmetic issues, minor typos, nitpicks).
|
||||||
|
6. **Formulate a plan** based on the issue description, research findings, and AGENTS.md.
|
||||||
|
7. **Implement the fix** following the repository workflow above.
|
||||||
|
8. **Verify the fix**:
|
||||||
|
- If AGENTS.md has verification steps, follow them.
|
||||||
|
- Otherwise, review the diff vs master and validate code quality.
|
||||||
|
- Research dependencies to ensure code standards are met.
|
||||||
|
9. **Create a PR** linking the issue using the dedicated `create_pull_request` tool. Do NOT use the `tea` CLI via `run_command`.
|
||||||
|
10. **Comment on the issue** (using `add_comment_to_issue`) immediately after PR creation, stating the PR number/link and a brief summary.
|
||||||
|
11. **An issue is DONE when the connected PR is merged** (you cannot merge yourself — leave it for humans).
|
||||||
|
|
||||||
|
#### When Fixing/Updating a PR (addressing review feedback):
|
||||||
|
1. **Read all review comments and change requests** on the PR carefully.
|
||||||
|
2. **Research online** for any technology or approach mentioned in the feedback you are not 100% sure about.
|
||||||
|
3. **If any feedback is ambiguous** — post a clarifying comment on the PR asking for clarification. Do NOT guess what the reviewer meant. Stop and wait for a reply.
|
||||||
|
4. **Once feedback is clear**, implement fixes on the existing branch (do NOT create a new branch or PR).
|
||||||
|
5. **Push and comment** on the PR with a summary of all changes made.
|
||||||
|
|
||||||
|
#### When Reviewing a PR:
|
||||||
|
1. **DO NOT write files, make commits, push branches, or create any new PRs**. Your only task is to review the existing PR.
|
||||||
|
2. **Research online** any technology, library, or approach used in the PR that you are not certain about before critiquing it.
|
||||||
|
3. **Check out the diff** vs master: `git diff origin/master...HEAD`.
|
||||||
|
4. **Review code quality**:
|
||||||
|
- Does it follow project conventions (check AGENTS.md)?
|
||||||
|
- Are there security issues?
|
||||||
|
- Is there proper error handling?
|
||||||
|
- Are edge cases covered?
|
||||||
|
- Are dependencies used correctly?
|
||||||
|
5. **Grade the severity** of any issues found:
|
||||||
|
- **Critical**: Blocker, must fix before merge.
|
||||||
|
- **Medium**: Should fix, but can merge with notes.
|
||||||
|
- **Low**: Nice-to-have, can defer.
|
||||||
|
6. **Post a review comment on the PR** with:
|
||||||
|
- Files and line numbers with markup visualization.
|
||||||
|
- Severity grade.
|
||||||
|
- Specific feedback and suggested fixes.
|
||||||
|
7. **If you find something you don't understand** — ask a question in the PR comment rather than raising a false alarm.
|
||||||
|
8. **DO NOT fix the issues yourself** during review. The author (another agent or human) will fix them in the next loop.
|
||||||
|
|
||||||
|
### 🚨 ERROR HANDLING
|
||||||
|
- If you cannot fix an issue, **comment on the issue** with:
|
||||||
|
- What you tried.
|
||||||
|
- What code changes you attempted.
|
||||||
|
- Why the fix failed.
|
||||||
|
- Do NOT mark the issue as done. The issue is only done when the PR is merged.
|
||||||
|
- If verification fails, continue debugging until it passes.
|
||||||
|
|
||||||
|
### 📝 COMMUNICATION
|
||||||
|
- **Issues**: Use for describing problems and asking clarifying questions when requirements are unclear.
|
||||||
|
- **PRs**: Use for proposing changes with detailed explanations.
|
||||||
|
- **PR Comments**: Use for review feedback, questions, and status updates.
|
||||||
|
- **When uncertain**: ALWAYS post a question as a comment and stop work. Never silently assume.
|
||||||
|
- **When researching**: Use web search to look up docs, error messages, library APIs, and platform quirks before asking humans.
|
||||||
|
- Always be specific and actionable in your comments.
|
||||||
|
|
||||||
|
### 🛑 FORBIDDEN ACTIONS
|
||||||
|
- Creating new repositories.
|
||||||
|
- Editing `.git` files (unless explicitly resolving a git issue).
|
||||||
|
- Merging PRs yourself (leave for humans).
|
||||||
|
- Working on non-`meeks` organization repos.
|
||||||
|
- Skipping AGENTS.md when available.
|
||||||
|
- Making unverified changes.
|
||||||
|
|
||||||
|
### ✅ SUCCESS CRITERIA
|
||||||
|
An issue is resolved when:
|
||||||
|
1. A PR is created with the fix.
|
||||||
|
2. The PR is linked to the issue (using `closes #N`).
|
||||||
|
3. The fix has been verified.
|
||||||
|
4. The PR has been reviewed (by another agent or human).
|
||||||
|
5. The PR is merged by a human.
|
||||||
|
|
||||||
|
You are the expert. Take charge. Follow the workflow exactly.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any, Callable
|
||||||
|
from core.agent import BaseAgent
|
||||||
|
from core.prompts import COORDINATOR_SYSTEM_PROMPT
|
||||||
|
from core.coordinator_tools import CoordinatorTools
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("agent-coordinator")
|
||||||
|
|
||||||
|
|
||||||
|
class CoordinatorNoToolCalledError(Exception):
|
||||||
|
"""Raised when the Coordinator Agent completes execution without calling any routing tool."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CoordinatorAgent(BaseAgent):
|
||||||
|
"""AI agent that coordinates Gitea issues and decides the next action."""
|
||||||
|
|
||||||
|
def __init__(self, model_name: str) -> None:
|
||||||
|
super().__init__(model_name)
|
||||||
|
self.system_prompt = COORDINATOR_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
async def decide_action(
|
||||||
|
self,
|
||||||
|
mission: str,
|
||||||
|
planning_tools: list[Callable[..., Any]],
|
||||||
|
coord_tools: CoordinatorTools,
|
||||||
|
) -> str:
|
||||||
|
"""Run the Coordinator Agent and ensure a tool is called."""
|
||||||
|
coord_tools_list: list[Callable[..., Any]] = [
|
||||||
|
coord_tools.propose_plan,
|
||||||
|
coord_tools.start_implementation,
|
||||||
|
coord_tools.answer_question,
|
||||||
|
coord_tools.close_issue,
|
||||||
|
coord_tools.take_no_action,
|
||||||
|
]
|
||||||
|
combined_tools: list[Callable[..., Any]] = planning_tools + coord_tools_list
|
||||||
|
|
||||||
|
logger.info("Running CoordinatorAgent to decide action...")
|
||||||
|
response_text: str = await self.run_with_tools(mission, combined_tools)
|
||||||
|
|
||||||
|
if not coord_tools.tool_called:
|
||||||
|
logger.warning("CoordinatorAgent did not call any tools!")
|
||||||
|
raise CoordinatorNoToolCalledError(
|
||||||
|
"CoordinatorAgent failed to call a routing tool during execution."
|
||||||
|
)
|
||||||
|
|
||||||
|
return response_text
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("coordinator-tools")
|
||||||
|
|
||||||
|
|
||||||
|
class CoordinatorTools:
|
||||||
|
"""Tools exposed to the Coordinator Agent for routing decisions."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.tool_called: bool = False
|
||||||
|
self.action: str = "NO_ACTION"
|
||||||
|
self.arguments: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def propose_plan(self, plan: str, issue_number: int) -> str:
|
||||||
|
"""Propose a step-by-step implementation plan to resolve the issue.
|
||||||
|
Use this when a code change is needed but no plan has been proposed yet,
|
||||||
|
or a plan was proposed but the human replied with feedback/changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plan: The detailed implementation plan.
|
||||||
|
issue_number: The Gitea issue number.
|
||||||
|
"""
|
||||||
|
logger.info(f"Coordinator tool 'propose_plan' called for issue #{issue_number}")
|
||||||
|
self.tool_called = True
|
||||||
|
self.action = "PROPOSE_PLAN"
|
||||||
|
self.arguments = {"plan": plan, "issue_number": issue_number}
|
||||||
|
return "Plan proposal recorded successfully."
|
||||||
|
|
||||||
|
def start_implementation(self, approved_plan: str, issue_number: int) -> str:
|
||||||
|
"""Enqueue/start implementation of the approved plan.
|
||||||
|
Use this ONLY if a plan was proposed and the human explicitly approved/greenlit it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
approved_plan: The plan that was approved, including any human feedback.
|
||||||
|
issue_number: The Gitea issue number.
|
||||||
|
"""
|
||||||
|
logger.info(f"Coordinator tool 'start_implementation' called for issue #{issue_number}")
|
||||||
|
self.tool_called = True
|
||||||
|
self.action = "EXECUTE_PLAN"
|
||||||
|
self.arguments = {"approved_plan": approved_plan, "issue_number": issue_number}
|
||||||
|
return "Implementation start recorded successfully."
|
||||||
|
|
||||||
|
def answer_question(self, answer: str, issue_number: int) -> str:
|
||||||
|
"""Provide a clear, helpful response to a question or information request.
|
||||||
|
Use this if the issue is just a question (no code changes needed).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
answer: The clear, helpful answer to the question.
|
||||||
|
issue_number: The Gitea issue number.
|
||||||
|
"""
|
||||||
|
logger.info(f"Coordinator tool 'answer_question' called for issue #{issue_number}")
|
||||||
|
self.tool_called = True
|
||||||
|
self.action = "ANSWER_QUESTION"
|
||||||
|
self.arguments = {"answer": answer, "issue_number": issue_number}
|
||||||
|
return "Answer recorded successfully."
|
||||||
|
|
||||||
|
def close_issue(self, comment: str, issue_number: int) -> str:
|
||||||
|
"""Close the issue.
|
||||||
|
Use this if the human confirmed they are satisfied or gave approval to close.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comment: A polite final comment explaining the closing of the issue.
|
||||||
|
issue_number: The Gitea issue number.
|
||||||
|
"""
|
||||||
|
logger.info(f"Coordinator tool 'close_issue' called for issue #{issue_number}")
|
||||||
|
self.tool_called = True
|
||||||
|
self.action = "CLOSE_ISSUE"
|
||||||
|
self.arguments = {"comment": comment, "issue_number": issue_number}
|
||||||
|
return "Close issue action recorded successfully."
|
||||||
|
|
||||||
|
def take_no_action(self) -> str:
|
||||||
|
"""Take no action on the issue.
|
||||||
|
Use this if the issue is already resolved or cannot proceed.
|
||||||
|
"""
|
||||||
|
logger.info("Coordinator tool 'take_no_action' called")
|
||||||
|
self.tool_called = True
|
||||||
|
self.action = "NO_ACTION"
|
||||||
|
self.arguments = {}
|
||||||
|
return "No action recorded successfully."
|
||||||
@@ -0,0 +1,763 @@
|
|||||||
|
"""Dispatches work to a specialized task processor, one repo at a time."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from core.queue import WorkItem
|
||||||
|
from core.coding_agent import CodingAgent
|
||||||
|
from core.planning_agent import PlanningAgent
|
||||||
|
from core.coordinator_agent import CoordinatorAgent, CoordinatorNoToolCalledError
|
||||||
|
from core.factory import AgentFactory
|
||||||
|
|
||||||
|
from gitea.tools.coding_tools import CodingTools
|
||||||
|
from gitea.tools.research_tools import ResearchTools
|
||||||
|
from gitea.tools.gitea_tools import GiteaTools
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
from core.coordinator_tools import CoordinatorTools
|
||||||
|
from gitea.workspace import WorkspaceManager
|
||||||
|
from gitea.config import AGENT_MODEL_ID
|
||||||
|
from gitea.models import CommentModel, PullRequestFileModel, PullRequestModel, IssueModel
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("agent-dispatcher")
|
||||||
|
|
||||||
|
AGENT_USERNAMES: frozenset[str] = frozenset({"meeks-ai", "agent-bot"})
|
||||||
|
|
||||||
|
CLOSE_KEYWORDS_PATTERN: re.Pattern[str] = re.compile(
|
||||||
|
rf"\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+#(\d+)\b",
|
||||||
|
re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_pr_for_issue_helper(client: GiteaClient, repo_full_name: str, issue_number: int) -> PullRequestModel | None:
|
||||||
|
"""Find an open pull request that addresses the given issue number."""
|
||||||
|
owner, repo_name = repo_full_name.split("/")
|
||||||
|
try:
|
||||||
|
prs = client.list_repo_pull_requests(owner, repo_name)
|
||||||
|
for pr in prs:
|
||||||
|
ref = pr.head.get("ref", "") if pr.head else ""
|
||||||
|
if re.search(rf"(?<!\d){issue_number}(?!\d)", ref):
|
||||||
|
return pr
|
||||||
|
body = pr.body or ""
|
||||||
|
title = pr.title or ""
|
||||||
|
matches = CLOSE_KEYWORDS_PATTERN.findall(body) + CLOSE_KEYWORDS_PATTERN.findall(title)
|
||||||
|
if any(int(m) == issue_number for m in matches):
|
||||||
|
return pr
|
||||||
|
issue_ref_pattern = re.compile(rf"(?<!\w)#{issue_number}\b")
|
||||||
|
if issue_ref_pattern.search(title) or issue_ref_pattern.search(body):
|
||||||
|
return pr
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error checking PRs for issue #{issue_number} in {repo_full_name}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_issues_for_pr_helper(pr_body: str) -> list[int]:
|
||||||
|
"""Extract referenced issue numbers from the PR body."""
|
||||||
|
matches = CLOSE_KEYWORDS_PATTERN.findall(pr_body)
|
||||||
|
return list(set(int(m) for m in matches))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_awaiting_reply_helper(comments: list[CommentModel]) -> bool:
|
||||||
|
"""Return True if the agent's most recent comment contains the
|
||||||
|
awaiting-reply marker AND no human has commented after it.
|
||||||
|
"""
|
||||||
|
if not comments:
|
||||||
|
return False
|
||||||
|
# Find the last agent comment index
|
||||||
|
last_agent_idx: int = -1
|
||||||
|
for i, c in enumerate(comments):
|
||||||
|
if c.user and c.user.login in AGENT_USERNAMES:
|
||||||
|
last_agent_idx = i
|
||||||
|
if last_agent_idx == -1:
|
||||||
|
return False
|
||||||
|
last_agent_comment = comments[last_agent_idx]
|
||||||
|
body = last_agent_comment.body or ""
|
||||||
|
# The agent explicitly embeds this marker when it is waiting for input
|
||||||
|
if "<!-- agent:awaiting-reply -->" not in body:
|
||||||
|
return False
|
||||||
|
# Check if any human replied AFTER the last agent comment
|
||||||
|
for c in comments[last_agent_idx + 1:]:
|
||||||
|
if c.user and c.user.login not in AGENT_USERNAMES:
|
||||||
|
return False # Human replied — we can proceed
|
||||||
|
return True # Agent signalled wait, no human replied yet
|
||||||
|
|
||||||
|
|
||||||
|
class TaskProcessor(ABC):
|
||||||
|
"""Abstract base class for processing Gitea tasks."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: GiteaClient,
|
||||||
|
tools: GiteaTools,
|
||||||
|
model_name: str,
|
||||||
|
repo: str,
|
||||||
|
item: WorkItem,
|
||||||
|
ai_username: str,
|
||||||
|
) -> None:
|
||||||
|
self.client = client
|
||||||
|
self.tools = tools
|
||||||
|
self.model_name = model_name
|
||||||
|
self.repo = repo
|
||||||
|
self.item = item
|
||||||
|
self.ai_username = ai_username
|
||||||
|
|
||||||
|
self.owner, self.repo_name = repo.split("/")
|
||||||
|
self.workspace = WorkspaceManager()
|
||||||
|
self.repo_path = self.workspace.get_repo_path(repo)
|
||||||
|
self.coding_tools = CodingTools(str(self.repo_path))
|
||||||
|
self.research_tools = ResearchTools()
|
||||||
|
|
||||||
|
self.planning_tools: list[Callable[..., Any]] = [
|
||||||
|
self.tools.get_issue,
|
||||||
|
self.tools.get_pull_request,
|
||||||
|
self.tools.list_issues,
|
||||||
|
self.tools.list_pull_requests,
|
||||||
|
self.tools.get_file_content,
|
||||||
|
self.tools.get_issue_comments,
|
||||||
|
self.tools.get_pull_request_comments,
|
||||||
|
self.tools.get_pull_request_diff,
|
||||||
|
self.tools.get_pull_request_patch,
|
||||||
|
self.coding_tools.list_files,
|
||||||
|
self.coding_tools.read_file,
|
||||||
|
self.coding_tools.grep_search,
|
||||||
|
self.coding_tools.get_working_directory,
|
||||||
|
self.coding_tools.run_command,
|
||||||
|
self.research_tools.web_search,
|
||||||
|
self.research_tools.fetch_url,
|
||||||
|
]
|
||||||
|
|
||||||
|
self.coding_tools_list: list[Callable[..., Any]] = [
|
||||||
|
self.tools.get_issue,
|
||||||
|
self.tools.get_pull_request,
|
||||||
|
self.tools.list_issues,
|
||||||
|
self.tools.list_pull_requests,
|
||||||
|
self.tools.get_file_content,
|
||||||
|
self.tools.create_pull_request,
|
||||||
|
self.tools.update_pull_request,
|
||||||
|
self.tools.add_label_to_issue,
|
||||||
|
self.tools.add_label_to_pr,
|
||||||
|
self.tools.create_branch,
|
||||||
|
self.tools.commit_file,
|
||||||
|
self.tools.create_issue,
|
||||||
|
self.tools.add_comment_to_issue,
|
||||||
|
self.tools.close_issue,
|
||||||
|
self.tools.close_pull_request,
|
||||||
|
self.tools.get_issue_comments,
|
||||||
|
self.tools.get_pull_request_comments,
|
||||||
|
self.tools.add_comment,
|
||||||
|
self.tools.add_label,
|
||||||
|
self.tools.update_file,
|
||||||
|
self.tools.get_pull_request_diff,
|
||||||
|
self.tools.get_pull_request_patch,
|
||||||
|
self.tools.approve_pull_request,
|
||||||
|
self.tools.request_changes,
|
||||||
|
self.coding_tools.list_files,
|
||||||
|
self.coding_tools.read_file,
|
||||||
|
self.coding_tools.write_file,
|
||||||
|
self.coding_tools.edit_file,
|
||||||
|
self.coding_tools.run_command,
|
||||||
|
self.coding_tools.grep_search,
|
||||||
|
self.coding_tools.get_working_directory,
|
||||||
|
self.research_tools.web_search,
|
||||||
|
self.research_tools.fetch_url,
|
||||||
|
]
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def process(self, attempt_limit: int) -> str:
|
||||||
|
"""Execute the task flow, including planning, coding, or coordination."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PRTaskProcessor(TaskProcessor):
|
||||||
|
"""Processes Gitea pull requests (code reviews and bug fixes)."""
|
||||||
|
|
||||||
|
def _build_pr_mission(self, pr_info: PullRequestModel, is_own_pr: bool) -> str:
|
||||||
|
pr_number = self.item.task_number
|
||||||
|
pr_details = pr_info.model_dump_json(indent=2)
|
||||||
|
pr_diff = ""
|
||||||
|
try:
|
||||||
|
pr_diff = self.client.get_pull_request_diff(self.owner, self.repo_name, pr_number)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not fetch PR diff for #{pr_number}: {e}")
|
||||||
|
pr_diff = f"Error fetching diff: {e}"
|
||||||
|
|
||||||
|
pr_files: list[PullRequestFileModel] = []
|
||||||
|
try:
|
||||||
|
pr_files = self.client.get_pull_request_files(self.owner, self.repo_name, pr_number)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
files_summary = "\n".join([f"- {f.filename}" for f in pr_files]) if pr_files else "No files available."
|
||||||
|
|
||||||
|
comments: list[CommentModel] = []
|
||||||
|
try:
|
||||||
|
comments = self.client.get_pull_request_comments(self.owner, self.repo_name, pr_number)
|
||||||
|
if not isinstance(comments, list):
|
||||||
|
comments = []
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
reviews: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
reviews = self.client.get_pr_reviews(self.owner, self.repo_name, pr_number)
|
||||||
|
if not isinstance(reviews, list):
|
||||||
|
reviews = []
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
timeline: list[dict[str, Any]] = []
|
||||||
|
for c in comments:
|
||||||
|
timeline.append({
|
||||||
|
"timestamp": c.created_at or "",
|
||||||
|
"user": c.user.login,
|
||||||
|
"type": "comment",
|
||||||
|
"body": c.body,
|
||||||
|
"by_ai": "Reviewed by AI Agent" in c.body or c.user.login == self.ai_username
|
||||||
|
})
|
||||||
|
for r in reviews:
|
||||||
|
r_user = (r.get("user") or {}).get("login", "unknown")
|
||||||
|
r_body = r.get("body", "")
|
||||||
|
r_state = r.get("state", "")
|
||||||
|
timeline.append({
|
||||||
|
"timestamp": r.get("submitted_at") or r.get("updated_at") or "",
|
||||||
|
"user": r_user,
|
||||||
|
"type": "review",
|
||||||
|
"body": f"[{r_state}] {r_body}",
|
||||||
|
"by_ai": r_user == self.ai_username
|
||||||
|
})
|
||||||
|
|
||||||
|
timeline.sort(key=lambda x: x["timestamp"])
|
||||||
|
|
||||||
|
last_action_by_ai = False
|
||||||
|
if timeline:
|
||||||
|
last_action_by_ai = timeline[-1]["by_ai"]
|
||||||
|
|
||||||
|
if last_action_by_ai:
|
||||||
|
logger.info(f"PR #{pr_number} already addressed by AI. Skipping.")
|
||||||
|
return f"SKIP: PR #{pr_number} has already been addressed by AI. No new action needed."
|
||||||
|
|
||||||
|
comments_str = "\n".join([
|
||||||
|
f"- @{c.user.login} ({c.created_at}): {c.body}"
|
||||||
|
for c in comments
|
||||||
|
]) if comments else "No comments yet."
|
||||||
|
|
||||||
|
reviews_str = "\n".join([
|
||||||
|
f"- @{(r.get('user') or {}).get('login')} ({r.get('submitted_at')}): [{r.get('state')}] {r.get('body')}"
|
||||||
|
for r in reviews
|
||||||
|
]) if reviews else "No reviews yet."
|
||||||
|
|
||||||
|
connected_issues_ctx = ""
|
||||||
|
pr_body = pr_info.body or ""
|
||||||
|
linked_issues = _find_issues_for_pr_helper(pr_body)
|
||||||
|
if linked_issues:
|
||||||
|
issues_details = []
|
||||||
|
for issue_num in linked_issues:
|
||||||
|
try:
|
||||||
|
issue = self.client.get_issue(self.owner, self.repo_name, issue_num)
|
||||||
|
issue_comments = self.client.get_issue_comments(self.owner, self.repo_name, issue_num)
|
||||||
|
comments_list = "\n".join([
|
||||||
|
f" - @{c.user.login} ({c.created_at}): {c.body}"
|
||||||
|
for c in issue_comments
|
||||||
|
]) if issue_comments else " No comments yet."
|
||||||
|
|
||||||
|
issues_details.append(
|
||||||
|
f"### Connected Issue #{issue_num}: {issue.title}\n"
|
||||||
|
f"Author: @{issue.user.login} (created {issue.created_at})\n"
|
||||||
|
f"Description:\n{issue.body or 'No description'}\n"
|
||||||
|
f"Discussion:\n{comments_list}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not fetch connected issue #{issue_num}: {e}")
|
||||||
|
if issues_details:
|
||||||
|
connected_issues_ctx = "\n---\n\n## 📋 CONNECTED ISSUE CONTEXT\n" + "\n\n".join(issues_details)
|
||||||
|
|
||||||
|
pr_head_branch = pr_info.head.get('ref', 'unknown') if pr_info.head else "unknown"
|
||||||
|
pr_base_branch = pr_info.base.get('ref', 'unknown') if pr_info.base else "unknown"
|
||||||
|
pr_state = pr_info.state
|
||||||
|
pr_created = pr_info.created_at or "unknown"
|
||||||
|
|
||||||
|
is_fixing_pr = is_own_pr or any(r.get("state") == "REQUEST_CHANGES" for r in reviews)
|
||||||
|
|
||||||
|
if is_fixing_pr:
|
||||||
|
instructions = (
|
||||||
|
f" Note: The repository is located locally at '{self.repo_path}'. This repository directory is your current working directory (cwd). You can verify this via the `get_working_directory` tool.\n"
|
||||||
|
f" Your task is to FIX/UPDATE this PR by addressing comments/change requests.\n"
|
||||||
|
f" DO NOT create a new branch or PR. Follow this exact workflow:\n"
|
||||||
|
f" 1. Checkout the PR's head branch: `git checkout {pr_head_branch}`\n"
|
||||||
|
f" 2. Implement the requested fixes or changes on this branch.\n"
|
||||||
|
f" 3. Verify your fixes and run verification/tests.\n"
|
||||||
|
f" 4. Commit and push the changes directly: `git add <files> && git commit -m \"fix: address feedback\" && git push origin {pr_head_branch}`\n"
|
||||||
|
f" 5. After pushing, comment on the PR (using the `add_comment` tool) with a summary of the fixes implemented."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
instructions = (
|
||||||
|
f" Note: The repository is located locally at '{self.repo_path}'. This repository directory is your current working directory (cwd). You can verify this via the `get_working_directory` tool.\n"
|
||||||
|
" CRITICAL: You are ONLY reviewing this PR. DO NOT edit files, DO NOT make commits, DO NOT push branches, and DO NOT create any new PRs.\n"
|
||||||
|
"1. Read the PR diff carefully.\n"
|
||||||
|
"2. Analyze the changes for correctness, quality, and potential issues.\n"
|
||||||
|
"3. Check for: code quality, security issues, edge cases, test coverage.\n"
|
||||||
|
"4. If the PR is good: approve it (using approve_pull_request tool) with a meaningful comment.\n"
|
||||||
|
"5. If the PR has issues: request changes (using request_changes tool) with specific feedback.\n"
|
||||||
|
"6. Post your review comment on the PR (using add_comment tool).\n"
|
||||||
|
"IMPORTANT: Never merge the PR yourself - that is handled by humans."
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"Your mission is to process PR #{pr_number} in {self.repo}.\n\n"
|
||||||
|
f"PR: {pr_info.title}\n"
|
||||||
|
f"Author: @{pr_info.user.login if pr_info.user else 'unknown'}\n"
|
||||||
|
f"Branch: {pr_head_branch} → {pr_base_branch}\n"
|
||||||
|
f"State: {pr_state} (created {pr_created})\n\n"
|
||||||
|
f"Description:\n{pr_info.body or 'No description'}\n\n"
|
||||||
|
f"Files Changed ({len(pr_files)}):\n{files_summary}\n\n"
|
||||||
|
f"Timeline Comments:\n{comments_str}\n\n"
|
||||||
|
f"Reviews:\n{reviews_str}\n\n"
|
||||||
|
f"{connected_issues_ctx}\n\n"
|
||||||
|
"BEFORE MAKING ANY CHANGES:\n"
|
||||||
|
" - Search online for any technology, API, or behavior you are not 100% certain about.\n"
|
||||||
|
" - Read ALL review comments and change requests carefully.\n"
|
||||||
|
" - If any review comment is ambiguous or unclear:\n"
|
||||||
|
" → Post a clarifying comment on the PR (using the `add_comment` tool) with your specific question(s).\n"
|
||||||
|
" → End the comment with the marker: <!-- agent:awaiting-reply --> on its own line.\n"
|
||||||
|
" → STOP. Do NOT implement anything until a human replies. The system will re-dispatch you once a human responds.\n"
|
||||||
|
" - Never assume or guess what a reviewer meant. Always prefer asking over guessing.\n\n"
|
||||||
|
f"Instructions:\n{instructions}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def process(self, attempt_limit: int) -> str:
|
||||||
|
try:
|
||||||
|
pr_detail = self.client.get_pull_request(self.owner, self.repo_name, self.item.task_number)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error fetching PR #{self.item.task_number} detail: {e}")
|
||||||
|
return f"FAILED: Could not fetch details for PR #{self.item.task_number}."
|
||||||
|
|
||||||
|
is_own_pr = (pr_detail.user and pr_detail.user.login == self.ai_username)
|
||||||
|
is_requested_reviewer = any(r.login == self.ai_username for r in pr_detail.requested_reviewers)
|
||||||
|
|
||||||
|
if not is_own_pr and not is_requested_reviewer:
|
||||||
|
logger.info(f"PR #{self.item.task_number}: Agent is not a requested reviewer. Skipping.")
|
||||||
|
return f"SKIP: Agent is not a requested reviewer on PR #{self.item.task_number}."
|
||||||
|
|
||||||
|
pr_comments = []
|
||||||
|
try:
|
||||||
|
pr_comments = self.client.get_pull_request_comments(self.owner, self.repo_name, self.item.task_number)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if _is_awaiting_reply_helper(pr_comments):
|
||||||
|
logger.info(f"PR #{self.item.task_number}: agent asked a question and is awaiting a human reply. Skipping.")
|
||||||
|
return f"SKIP: Awaiting human reply on PR #{self.item.task_number}."
|
||||||
|
|
||||||
|
base_mission = self._build_pr_mission(pr_detail, is_own_pr)
|
||||||
|
if base_mission.startswith("SKIP:"):
|
||||||
|
logger.info(f"Skipping task #{self.item.task_number}: {base_mission}")
|
||||||
|
return base_mission
|
||||||
|
|
||||||
|
for attempt in range(1, attempt_limit + 1):
|
||||||
|
try:
|
||||||
|
logger.info(f"Starting Planning Phase for PR #{self.item.task_number} (attempt {attempt})")
|
||||||
|
planning_mission = (
|
||||||
|
f"PHASE 1: PLANNING PHASE\n\n"
|
||||||
|
f"Your task is to research the problem, analyse the repository structure, and produce a detailed implementation plan.\n"
|
||||||
|
f"Original Mission details:\n{base_mission}\n\n"
|
||||||
|
f"CRITICAL RULES:\n"
|
||||||
|
f"1. You are ONLY generating an implementation plan. DO NOT write files, DO NOT edit files, DO NOT commit, DO NOT push, and DO NOT create branches or PRs.\n"
|
||||||
|
f"2. RESEARCH FIRST (mandatory before writing the plan):\n"
|
||||||
|
f" - Use web_search to find relevant documentation, known solutions, library APIs, error explanations, and best practices.\n"
|
||||||
|
f" - Use fetch_url to read specific documentation pages, changelogs, or Stack Overflow answers in full.\n"
|
||||||
|
f"3. EXPLORE the codebase using read_file, list_files, grep_search, or run_command.\n"
|
||||||
|
f"4. Output your final plan clearly.\n"
|
||||||
|
)
|
||||||
|
planning_agent = PlanningAgent(self.model_name)
|
||||||
|
plan = await planning_agent.run_with_tools(planning_mission, self.planning_tools)
|
||||||
|
|
||||||
|
logger.info(f"Starting Coding Phase for PR #{self.item.task_number} (attempt {attempt})")
|
||||||
|
coding_mission = (
|
||||||
|
f"PHASE 2: EXECUTION/CODING PHASE\n\n"
|
||||||
|
f"You must now implement the changes based on the following plan:\n"
|
||||||
|
f"--- PLAN ---\n{plan}\n--- PLAN END ---\n\n"
|
||||||
|
f"Original Mission details:\n{base_mission}\n\n"
|
||||||
|
f"Follow the workflow to implement changes, verify, and complete PR review/updates.\n"
|
||||||
|
)
|
||||||
|
coding_agent = CodingAgent(self.model_name)
|
||||||
|
response = await coding_agent.run_with_tools(coding_mission, self.coding_tools_list)
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing PR #{self.item.task_number} (attempt {attempt}/{attempt_limit}): {e}")
|
||||||
|
if attempt == attempt_limit:
|
||||||
|
return f"FAILED after {attempt_limit} attempts: {str(e)}"
|
||||||
|
return f"FAILED: PR #{self.item.task_number} not processed."
|
||||||
|
|
||||||
|
|
||||||
|
class IssueTaskProcessor(TaskProcessor):
|
||||||
|
"""Processes Gitea issues (acting as coordinator, then coding)."""
|
||||||
|
|
||||||
|
def _build_issue_mission(self, issue_info: IssueModel, branch_name: str) -> str:
|
||||||
|
issue_number = self.item.task_number
|
||||||
|
issue_body = issue_info.body or "No description provided."
|
||||||
|
issue_labels = [lbl.name for lbl in issue_info.labels]
|
||||||
|
issue_user = issue_info.user.login if issue_info.user else "unknown"
|
||||||
|
issue_created = issue_info.created_at or "unknown"
|
||||||
|
title = issue_info.title
|
||||||
|
|
||||||
|
comments: list[CommentModel] = []
|
||||||
|
try:
|
||||||
|
comments = self.client.get_issue_comments(self.owner, self.repo_name, issue_number)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
labels_str = f"Labels: {', '.join(issue_labels)}" if issue_labels else "Labels: none"
|
||||||
|
comments_str = "\n".join([
|
||||||
|
f"- @{c.user.login} ({c.created_at}): {c.body}"
|
||||||
|
for c in comments
|
||||||
|
]) if comments else "No comments yet."
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"Your mission is to resolve issue #{issue_number} in {self.repo}.\n\n"
|
||||||
|
f"Issue: {title}\n"
|
||||||
|
f"Author: @{issue_user} (created {issue_created})\n"
|
||||||
|
f"{labels_str}\n\n"
|
||||||
|
f"Description:\n{issue_body}\n\n"
|
||||||
|
f"Comments ({len(comments)}):\n{comments_str}\n\n"
|
||||||
|
f"Branch name: {branch_name}.\n\n"
|
||||||
|
"BEFORE WRITING ANY CODE:\n"
|
||||||
|
" - Search online for relevant documentation, known solutions, library APIs, and platform-specific behavior.\n"
|
||||||
|
" - If ANY part of the issue is unclear, ambiguous, or has multiple valid approaches:\n"
|
||||||
|
" → Post a comment on the issue using `add_comment_to_issue` with your specific question(s).\n"
|
||||||
|
" → List the approaches you are considering.\n"
|
||||||
|
" → End the comment with the marker: <!-- agent:awaiting-reply --> on its own line.\n"
|
||||||
|
" → STOP. Do NOT proceed until a human replies. The system will re-dispatch you once a human responds.\n"
|
||||||
|
" - Never assume or guess. Always prefer asking over guessing.\n\n"
|
||||||
|
"CRITICAL INSTRUCTIONS:\n"
|
||||||
|
f"1. The repo is already cloned locally at '{self.repo_path}'. DO NOT create a new repository.\n"
|
||||||
|
f" The repository directory is your current working directory (cwd). You can verify this via the `get_working_directory` tool.\n"
|
||||||
|
"2. Always start from master: `git checkout master && git pull origin master`\n"
|
||||||
|
"3. Branch from master: `git checkout -b <type>/issue-<number>-<descriptive-name>`\n"
|
||||||
|
" Branch names MUST include a descriptive name (words/hyphens), not just the issue number.\n"
|
||||||
|
" Types: feat, fix, chore, docs, style, refactor, test, build, ci, perf\n"
|
||||||
|
"4. Use `edit_file`/`write_file` for code changes, then `git add` and `git commit` via `run_command`.\n"
|
||||||
|
"5. Push: `git push origin <branch>` via `run_command`.\n"
|
||||||
|
"6. Create PR: Use the `create_pull_request` tool (do NOT use Gitea's `tea` CLI or GitHub's `gh` CLI in run_command, as they can hang/freeze interactively).\n"
|
||||||
|
" PR description MUST include the Gitea automation template with 'closes #<ISSUE>'.\n"
|
||||||
|
"7. IMPORTANT: After successfully creating the pull request, you MUST comment on the issue (using the `add_comment_to_issue` tool) with the PR number, PR link, and summary.\n"
|
||||||
|
"8. Check AGENTS.md in repo root for project conventions and verification steps.\n"
|
||||||
|
"9. Grade severity: Critical/High = fix, Medium = review/fix, Low = skip.\n"
|
||||||
|
"An issue is DONE when the connected PR is merged (you cannot merge yourself).\n"
|
||||||
|
"DO NOT edit .git files unless explicitly resolving a git issue.\n"
|
||||||
|
"DO NOT work on non-meeks organization repos."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def process(self, attempt_limit: int) -> str:
|
||||||
|
# Check if there is an existing PR for the issue
|
||||||
|
existing_pr = _find_pr_for_issue_helper(self.client, self.repo, self.item.task_number)
|
||||||
|
is_wip = False
|
||||||
|
has_request_changes = False
|
||||||
|
|
||||||
|
if existing_pr:
|
||||||
|
if existing_pr.title:
|
||||||
|
title_upper = existing_pr.title.strip().upper()
|
||||||
|
is_wip = title_upper.startswith("WIP:") or "[WIP]" in title_upper
|
||||||
|
|
||||||
|
try:
|
||||||
|
reviews = self.client.get_pr_reviews(self.owner, self.repo_name, existing_pr.number)
|
||||||
|
has_request_changes = any(r.get("state") == "REQUEST_CHANGES" for r in reviews)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error checking reviews for PR #{existing_pr.number}: {e}")
|
||||||
|
|
||||||
|
if not is_wip and not has_request_changes:
|
||||||
|
logger.info(f"Issue #{self.item.task_number} already has open PR #{existing_pr.number}. Skipping.")
|
||||||
|
return f"SKIP: A pull request (PR #{existing_pr.number}) addressing issue #{self.item.task_number} already exists."
|
||||||
|
|
||||||
|
# Check comments on issue and PR
|
||||||
|
issue_comments = []
|
||||||
|
try:
|
||||||
|
issue_comments = self.client.get_issue_comments(self.owner, self.repo_name, self.item.task_number)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
pr_comments = []
|
||||||
|
if existing_pr:
|
||||||
|
try:
|
||||||
|
pr_comments = self.client.get_pull_request_comments(self.owner, self.repo_name, existing_pr.number)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if _is_awaiting_reply_helper(issue_comments) or _is_awaiting_reply_helper(pr_comments):
|
||||||
|
logger.info(f"Issue #{self.item.task_number}: awaiting human reply. Skipping.")
|
||||||
|
return f"SKIP: Awaiting human reply on issue #{self.item.task_number} or PR."
|
||||||
|
|
||||||
|
issue_info = self.item.task_info
|
||||||
|
assert isinstance(issue_info, IssueModel)
|
||||||
|
title = issue_info.title
|
||||||
|
issue_body = issue_info.body or "No description provided."
|
||||||
|
|
||||||
|
issue_comments_str = "\n".join([
|
||||||
|
f"- @{c.user.login} ({c.created_at}): {c.body}" for c in issue_comments
|
||||||
|
]) if issue_comments else "No comments yet."
|
||||||
|
|
||||||
|
pr_info_str = "No existing PR."
|
||||||
|
if existing_pr:
|
||||||
|
pr_comments_str = "\n".join([
|
||||||
|
f"- @{c.user.login} ({c.created_at}): {c.body}" for c in pr_comments
|
||||||
|
]) if pr_comments else "No PR comments yet."
|
||||||
|
try:
|
||||||
|
reviews = self.client.get_pr_reviews(self.owner, self.repo_name, existing_pr.number)
|
||||||
|
reviews_str = "\n".join([
|
||||||
|
f"- @{r.get('user', {}).get('login')} ({r.get('submitted_at')}): [{r.get('state')}] {r.get('body')}"
|
||||||
|
for r in reviews
|
||||||
|
]) if reviews else "No reviews yet."
|
||||||
|
except Exception:
|
||||||
|
reviews_str = "No reviews available."
|
||||||
|
pr_info_str = (
|
||||||
|
f"PR Number: #{existing_pr.number}\n"
|
||||||
|
f"PR Title: {existing_pr.title}\n"
|
||||||
|
f"PR Branch: {existing_pr.head.get('ref', 'unknown')}\n"
|
||||||
|
f"PR State: {existing_pr.state}\n"
|
||||||
|
f"PR Comments:\n{pr_comments_str}\n"
|
||||||
|
f"PR Reviews:\n{reviews_str}"
|
||||||
|
)
|
||||||
|
|
||||||
|
state_analysis_mission = (
|
||||||
|
f"Analyzing issue #{self.item.task_number} in '{self.repo}'.\n\n"
|
||||||
|
f"Issue Title: {title}\n"
|
||||||
|
f"Description:\n{issue_body}\n\n"
|
||||||
|
f"Issue Comments:\n{issue_comments_str}\n\n"
|
||||||
|
f"Existing PR Details:\n{pr_info_str}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
for attempt in range(1, attempt_limit + 1):
|
||||||
|
try:
|
||||||
|
coord_tools = CoordinatorTools()
|
||||||
|
coordinator_agent = CoordinatorAgent(self.model_name)
|
||||||
|
|
||||||
|
logger.info(f"Analyzing conversation state for issue #{self.item.task_number}...")
|
||||||
|
await coordinator_agent.decide_action(state_analysis_mission, self.planning_tools, coord_tools)
|
||||||
|
|
||||||
|
action = coord_tools.action
|
||||||
|
logger.info(f"Coordinator Decided Action: {action} (tool_called={coord_tools.tool_called})")
|
||||||
|
|
||||||
|
if action == "PROPOSE_PLAN":
|
||||||
|
comment_body = coord_tools.arguments.get("comment_body", "")
|
||||||
|
if not comment_body:
|
||||||
|
plan = coord_tools.arguments.get("plan", "")
|
||||||
|
comment_body = (
|
||||||
|
f"### Proposed Implementation Plan\n\n"
|
||||||
|
f"{plan}\n\n"
|
||||||
|
f"Is this plan ok for implementation or do you have any comments/changes?\n"
|
||||||
|
f"<!-- agent:plan-proposal -->\n"
|
||||||
|
f"<!-- agent:awaiting-reply -->"
|
||||||
|
)
|
||||||
|
self.client.add_comment(self.owner, self.repo_name, self.item.task_number, comment_body)
|
||||||
|
return f"POSTED_COMMENT: PROPOSE_PLAN comment posted to issue #{self.item.task_number}."
|
||||||
|
|
||||||
|
elif action == "ANSWER_QUESTION":
|
||||||
|
comment_body = coord_tools.arguments.get("comment_body", "")
|
||||||
|
if not comment_body:
|
||||||
|
answer = coord_tools.arguments.get("answer", "")
|
||||||
|
comment_body = (
|
||||||
|
f"{answer}\n\n"
|
||||||
|
f"Is this answer satisfactory?\n"
|
||||||
|
f"<!-- agent:question-response -->\n"
|
||||||
|
f"<!-- agent:awaiting-reply -->"
|
||||||
|
)
|
||||||
|
self.client.add_comment(self.owner, self.repo_name, self.item.task_number, comment_body)
|
||||||
|
return f"POSTED_COMMENT: ANSWER_QUESTION comment posted to issue #{self.item.task_number}."
|
||||||
|
|
||||||
|
elif action == "CLOSE_ISSUE":
|
||||||
|
comment = coord_tools.arguments.get("comment", "Closing the issue as resolved.")
|
||||||
|
self.client.add_comment(self.owner, self.repo_name, self.item.task_number, comment)
|
||||||
|
self.client.close_issue(self.owner, self.repo_name, self.item.task_number)
|
||||||
|
return f"CLOSED_ISSUE: Issue #{self.item.task_number} closed."
|
||||||
|
|
||||||
|
elif action == "NO_ACTION":
|
||||||
|
return f"NO_ACTION: No action taken on issue #{self.item.task_number}."
|
||||||
|
|
||||||
|
elif action == "EXECUTE_PLAN":
|
||||||
|
approved_plan = coord_tools.arguments.get("approved_plan", "")
|
||||||
|
pr_to_use = existing_pr
|
||||||
|
branch_name = ""
|
||||||
|
|
||||||
|
if pr_to_use:
|
||||||
|
branch_name = pr_to_use.head.get("ref", "")
|
||||||
|
logger.info(f"Resuming work on existing PR #{pr_to_use.number} on branch '{branch_name}'")
|
||||||
|
else:
|
||||||
|
logger.info(f"Creating new WIP PR for issue #{self.item.task_number}")
|
||||||
|
clean_title = re.sub(r'[^a-zA-Z0-9\s-]', '', title).strip().lower()
|
||||||
|
title_words = clean_title.split()[:5]
|
||||||
|
desc_suffix = "-".join(title_words)
|
||||||
|
if not desc_suffix:
|
||||||
|
desc_suffix = "fix-issue"
|
||||||
|
branch_name = f"fix/issue-{self.item.task_number}-{desc_suffix}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(["git", "checkout", "master"], cwd=str(self.repo_path), check=True)
|
||||||
|
subprocess.run(["git", "pull", "origin", "master"], cwd=str(self.repo_path), check=True)
|
||||||
|
subprocess.run(["git", "branch", "-D", branch_name], cwd=str(self.repo_path), stderr=subprocess.DEVNULL)
|
||||||
|
subprocess.run(["git", "checkout", "-b", branch_name], cwd=str(self.repo_path), check=True)
|
||||||
|
subprocess.run(["git", "commit", "--allow-empty", "-m", f"WIP: start implementation for issue #{self.item.task_number}"], cwd=str(self.repo_path), check=True)
|
||||||
|
subprocess.run(["git", "push", "origin", branch_name], cwd=str(self.repo_path), check=True)
|
||||||
|
|
||||||
|
pr_title = f"WIP: {title}"
|
||||||
|
pr_description = f"Work in progress for issue #{self.item.task_number}."
|
||||||
|
pr_to_use = self.client.create_pull_request(
|
||||||
|
self.owner, self.repo_name, head=branch_name, base="master", title=pr_title, description=pr_description
|
||||||
|
)
|
||||||
|
|
||||||
|
pr_link = pr_to_use.html_url or f"{self.client.base_url}/{self.repo}/pulls/{pr_to_use.number}"
|
||||||
|
start_comment = f"Started work on PR #{pr_to_use.number} ({pr_link})."
|
||||||
|
self.client.add_comment(self.owner, self.repo_name, self.item.task_number, start_comment)
|
||||||
|
|
||||||
|
logger.info(f"Successfully created WIP PR #{pr_to_use.number} on branch '{branch_name}'")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create WIP PR for issue #{self.item.task_number}: {e}")
|
||||||
|
return f"FAILED to create WIP PR: {e}"
|
||||||
|
|
||||||
|
base_mission = self._build_issue_mission(issue_info, branch_name)
|
||||||
|
coding_mission = (
|
||||||
|
f"PHASE 2: EXECUTION/CODING PHASE\n\n"
|
||||||
|
f"You are implementing changes for issue #{self.item.task_number} in repository '{self.repo}'.\n"
|
||||||
|
f"You are working on the existing Pull Request #{pr_to_use.number} on branch '{branch_name}'.\n\n"
|
||||||
|
f"--- APPROVED PLAN ---\n{approved_plan}\n--- APPROVED PLAN END ---\n\n"
|
||||||
|
f"Original Mission details:\n{base_mission}\n\n"
|
||||||
|
f"DIRECTIONS:\n"
|
||||||
|
f"1. Checkout the branch '{branch_name}' (it should already be checked out, or run `git checkout {branch_name}`).\n"
|
||||||
|
f"2. Implement the changes according to the APPROVED PLAN.\n"
|
||||||
|
f"3. Run verification/tests (check AGENTS.md for conventions).\n"
|
||||||
|
f"4. Commit and push your changes to origin on the branch '{branch_name}'.\n"
|
||||||
|
f"5. ONCE COMPLETED SUCCESSFULLY:\n"
|
||||||
|
f" - Call `update_pull_request(owner='{self.owner}', repo='{self.repo_name}', pull_number={pr_to_use.number}, title='{title}', body='<filled PR template>')`.\n"
|
||||||
|
f" Note: The PR title must not contain 'WIP:'. The PR body must follow the mandatory PR template in CODING AGENT SYSTEM PROMPT.\n"
|
||||||
|
f" - Call `add_comment_to_issue(owner='{self.owner}', repo='{self.repo_name}', issue_number={self.item.task_number}, body='Work has been completed in PR #{pr_to_use.number}.')`.\n"
|
||||||
|
f"6. IF YOU ENCOUNTER A BLOCKER OR FAIL:\n"
|
||||||
|
f" - You are allowed (and encouraged) to comment on the WIP PR #{pr_to_use.number} (using `add_comment`) with any details, logs, or context to help the next agent resume the work.\n\n"
|
||||||
|
f"AVAILABLE RESEARCH TOOLS:\n"
|
||||||
|
f" - web_search(query, time_range, categories) — search the web via SearXNG/DuckDuckGo\n"
|
||||||
|
f" - fetch_url(url) — read any documentation page in full\n"
|
||||||
|
)
|
||||||
|
logger.info(f"Starting Execution/Coding Phase for issue #{self.item.task_number} on branch '{branch_name}'")
|
||||||
|
coding_agent = CodingAgent(self.model_name)
|
||||||
|
response = await coding_agent.run_with_tools(coding_mission, self.coding_tools_list)
|
||||||
|
logger.info(f"Agent response for issue #{self.item.task_number}: {response}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
except CoordinatorNoToolCalledError as e:
|
||||||
|
logger.error(f"Coordinator error on issue #{self.item.task_number} (attempt {attempt}/{attempt_limit}): {e}")
|
||||||
|
if attempt == attempt_limit:
|
||||||
|
return f"FAILED: Coordinator did not call any tools after {attempt_limit} attempts."
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing issue #{self.item.task_number} (attempt {attempt}/{attempt_limit}): {e}")
|
||||||
|
if attempt == attempt_limit:
|
||||||
|
return f"FAILED after {attempt_limit} attempts: {str(e)}"
|
||||||
|
return f"FAILED: Issue #{self.item.task_number} not processed."
|
||||||
|
|
||||||
|
|
||||||
|
class AgentDispatcher:
|
||||||
|
"""Dispatches work to a specialized task processor, one repo at a time."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: GiteaClient,
|
||||||
|
tools: GiteaTools,
|
||||||
|
model_name: str = AGENT_MODEL_ID,
|
||||||
|
max_retries: int = 2,
|
||||||
|
) -> None:
|
||||||
|
self._client = client
|
||||||
|
self._tools = tools
|
||||||
|
self._model_name = model_name
|
||||||
|
self._max_retries = max_retries
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self,
|
||||||
|
repo: str,
|
||||||
|
work_items: list[WorkItem],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Dispatch all work for a single repo by handling specialized processor classes."""
|
||||||
|
workspace = WorkspaceManager()
|
||||||
|
repo_path = workspace.get_repo_path(repo)
|
||||||
|
|
||||||
|
original_cwd = os.getcwd()
|
||||||
|
changed_dir = False
|
||||||
|
if os.path.isdir(str(repo_path)):
|
||||||
|
os.chdir(str(repo_path))
|
||||||
|
changed_dir = True
|
||||||
|
|
||||||
|
results: list[str] = []
|
||||||
|
try:
|
||||||
|
# Get authenticated username for reviewer filter
|
||||||
|
ai_username = "meeks-ai"
|
||||||
|
try:
|
||||||
|
user = self._client.get_authenticated_user()
|
||||||
|
if user:
|
||||||
|
ai_username = user.login
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for item in work_items:
|
||||||
|
processor: TaskProcessor
|
||||||
|
if item.task_type == "pr":
|
||||||
|
processor = PRTaskProcessor(
|
||||||
|
client=self._client,
|
||||||
|
tools=self._tools,
|
||||||
|
model_name=self._model_name,
|
||||||
|
repo=repo,
|
||||||
|
item=item,
|
||||||
|
ai_username=ai_username,
|
||||||
|
)
|
||||||
|
elif item.task_type == "issue":
|
||||||
|
processor = IssueTaskProcessor(
|
||||||
|
client=self._client,
|
||||||
|
tools=self._tools,
|
||||||
|
model_name=self._model_name,
|
||||||
|
repo=repo,
|
||||||
|
item=item,
|
||||||
|
ai_username=ai_username,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown task type: {item.task_type}")
|
||||||
|
results.append(f"SKIP: Unknown task type {item.task_type}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Processing {item.task_type} #{item.task_number} via {processor.__class__.__name__}")
|
||||||
|
result = await processor.process(attempt_limit=self._max_retries)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if changed_dir:
|
||||||
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
# Backward compatibility helper methods for unit tests
|
||||||
|
def _find_pr_for_issue(self, repo_full_name: str, issue_number: int) -> PullRequestModel | None:
|
||||||
|
return _find_pr_for_issue_helper(self._client, repo_full_name, issue_number)
|
||||||
|
|
||||||
|
def _is_awaiting_reply(self, comments: list[CommentModel]) -> bool:
|
||||||
|
return _is_awaiting_reply_helper(comments)
|
||||||
|
|
||||||
|
def _build_pr_mission(self, item: WorkItem) -> str:
|
||||||
|
pr_info = item.task_info
|
||||||
|
assert isinstance(pr_info, PullRequestModel)
|
||||||
|
processor = PRTaskProcessor(
|
||||||
|
client=self._client,
|
||||||
|
tools=self._tools,
|
||||||
|
model_name=self._model_name,
|
||||||
|
repo=item.repo_full_name,
|
||||||
|
item=item,
|
||||||
|
ai_username="meeks-ai",
|
||||||
|
)
|
||||||
|
return processor._build_pr_mission(pr_info, is_own_pr=False)
|
||||||
|
|
||||||
|
def _build_issue_mission(self, item: WorkItem) -> str:
|
||||||
|
issue_info = item.task_info
|
||||||
|
assert isinstance(issue_info, IssueModel)
|
||||||
|
processor = IssueTaskProcessor(
|
||||||
|
client=self._client,
|
||||||
|
tools=self._tools,
|
||||||
|
model_name=self._model_name,
|
||||||
|
repo=item.repo_full_name,
|
||||||
|
item=item,
|
||||||
|
ai_username="meeks-ai",
|
||||||
|
)
|
||||||
|
return processor._build_issue_mission(issue_info, "dummy-branch")
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import logging
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
from core.interfaces import (
|
||||||
|
IssuesClient,
|
||||||
|
PullRequestsClient,
|
||||||
|
FilesClient,
|
||||||
|
RefsClient,
|
||||||
|
ReposClient,
|
||||||
|
)
|
||||||
|
from core.coding_agent import CodingAgent
|
||||||
|
from core.agent import CavemanAgent
|
||||||
|
from core.coordinator_agent import CoordinatorAgent
|
||||||
|
from core.planning_agent import PlanningAgent
|
||||||
|
from core.notification_agent import NotificationReaderAgent
|
||||||
|
from gitea.workspace import WorkspaceManager
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("core-factory")
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaClientFactory:
|
||||||
|
"""Factory for creating Gitea client components with dependency injection support."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_full_client() -> GiteaClient:
|
||||||
|
return GiteaClient()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_issues_client(client: GiteaClient | None = None) -> IssuesClient:
|
||||||
|
if client is None:
|
||||||
|
client = GiteaClient()
|
||||||
|
return client
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_prs_client(client: GiteaClient | None = None) -> PullRequestsClient:
|
||||||
|
if client is None:
|
||||||
|
client = GiteaClient()
|
||||||
|
return client
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_files_client(client: GiteaClient | None = None) -> FilesClient:
|
||||||
|
if client is None:
|
||||||
|
client = GiteaClient()
|
||||||
|
return client
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_refs_client(client: GiteaClient | None = None) -> RefsClient:
|
||||||
|
if client is None:
|
||||||
|
client = GiteaClient()
|
||||||
|
return client
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_repos_client(client: GiteaClient | None = None) -> ReposClient:
|
||||||
|
if client is None:
|
||||||
|
client = GiteaClient()
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
class AgentFactory:
|
||||||
|
"""Factory for creating AI agent instances."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_coding_agent(model_name: str) -> CodingAgent:
|
||||||
|
logger.info(f"Factory creating CodingAgent with model: {model_name}")
|
||||||
|
return CodingAgent(model_name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_caveman_agent(model_name: str) -> CavemanAgent:
|
||||||
|
logger.info(f"Factory creating CavemanAgent with model: {model_name}")
|
||||||
|
return CavemanAgent(model_name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_coordinator_agent(model_name: str) -> CoordinatorAgent:
|
||||||
|
logger.info(f"Factory creating CoordinatorAgent with model: {model_name}")
|
||||||
|
return CoordinatorAgent(model_name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_planning_agent(model_name: str) -> PlanningAgent:
|
||||||
|
logger.info(f"Factory creating PlanningAgent with model: {model_name}")
|
||||||
|
return PlanningAgent(model_name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_notification_reader_agent(model_name: str) -> NotificationReaderAgent:
|
||||||
|
logger.info(f"Factory creating NotificationReaderAgent with model: {model_name}")
|
||||||
|
return NotificationReaderAgent(model_name)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceFactory:
|
||||||
|
"""Factory for creating workspace manager instances."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_workspace() -> WorkspaceManager:
|
||||||
|
logger.info("Factory creating WorkspaceManager")
|
||||||
|
return WorkspaceManager()
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Interfaces for Gitea operations."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
from gitea.models import (
|
||||||
|
IssueModel,
|
||||||
|
PullRequestModel,
|
||||||
|
CommentModel,
|
||||||
|
LabelModel,
|
||||||
|
UserModel,
|
||||||
|
RepositoryModel,
|
||||||
|
PullRequestFileModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IssuesClient(ABC):
|
||||||
|
"""Interface for Gitea issue operations."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_repo_issues(self, owner: str, repo: str, state: str = "open") -> list[IssueModel]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def close_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_issue_comments(self, owner: str, repo: str, issue_number: int) -> list[CommentModel]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_assigned_issues(self, owner: str, repo: str) -> list[IssueModel]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_issue(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
labels: list[str] | None = None,
|
||||||
|
assignees: list[str] | None = None,
|
||||||
|
) -> IssueModel: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add_comment(self, owner: str, repo: str, issue_number: int, body: str) -> CommentModel: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add_label(self, owner: str, repo: str, issue_number: int, label: str) -> LabelModel: ...
|
||||||
|
|
||||||
|
|
||||||
|
class PullRequestsClient(ABC):
|
||||||
|
"""Interface for Gitea pull request operations."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_repo_pull_requests(self, owner: str, repo: str, state: str = "open") -> list[PullRequestModel]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def close_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_pull_request_comments(self, owner: str, repo: str, pull_number: int) -> list[CommentModel]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_pr_reviews(self, owner: str, repo: str, pr_number: int) -> list[dict[str, Any]]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_pull_request_diff(self, owner: str, repo: str, pull_number: int) -> str: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_pull_request_patch(self, owner: str, repo: str, pull_number: int) -> str: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_pull_request_files(self, owner: str, repo: str, pull_number: int) -> list[PullRequestFileModel]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_assigned_pull_requests(self, owner: str, repo: str) -> list[PullRequestModel]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_pull_request(
|
||||||
|
self, owner: str, repo: str, head: str, base: str, title: str, description: str = ""
|
||||||
|
) -> PullRequestModel: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def update_pull_request(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pull_number: int,
|
||||||
|
title: str | None = None,
|
||||||
|
body: str | None = None,
|
||||||
|
state: str | None = None,
|
||||||
|
) -> PullRequestModel: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def approve_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def request_changes_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def dismiss_review_pr(
|
||||||
|
self, owner: str, repo: str, pr_number: int, review_id: int, message: str
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def assign_issue(self, owner: str, repo: str, issue_number: int, username: str) -> IssueModel: ...
|
||||||
|
|
||||||
|
|
||||||
|
class FilesClient(ABC):
|
||||||
|
"""Interface for Gitea file/content operations."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_file_content(self, owner: str, repo: str, path: str, ref: str = "master") -> str | list[str]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def update_file(
|
||||||
|
self, owner: str, repo: str, path: str, message: str, content: str, branch: str
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class RefsClient(ABC):
|
||||||
|
"""Interface for Gitea git ref operations."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def update_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ReposClient(ABC):
|
||||||
|
"""Interface for Gitea repository operations."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_all_user_repos(self) -> list[RepositoryModel]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Agent(ABC):
|
||||||
|
"""Interface for AI agent operations."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def initialize(self) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def run(self, user_input: str) -> str: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def run_with_tools(self, user_input: str, tools: list[Any]) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Workspace(ABC):
|
||||||
|
"""Interface for workspace management."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_repo_path(self, repo_full_name: str) -> Any: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def sanitize_repo(self, repo_path: Any) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def clone_repo(self, repo_full_name: str, clone_url: str) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
class MissionBuilder(ABC):
|
||||||
|
"""Interface for mission string construction."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def build_issue_mission(self, issue_info: dict[str, Any], branch_name: str) -> str: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def build_pr_mission(self, pr_info: dict[str, Any]) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
|
class BranchStrategy(ABC):
|
||||||
|
"""Interface for branch creation strategy."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create_or_reuse_branch(self, repo_path: Any, branch_name: str, base_branch: str | None = None) -> str: ...
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any, Callable
|
||||||
|
from core.agent import BaseAgent
|
||||||
|
from core.prompts import NOTIFICATION_READER_SYSTEM_PROMPT
|
||||||
|
from core.notification_tools import NotificationTools
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("agent-notification-reader")
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationNoToolCalledError(Exception):
|
||||||
|
"""Raised when the Notification Reader Agent completes execution without calling any routing tool."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationReaderAgent(BaseAgent):
|
||||||
|
"""AI agent that reviews Gitea notifications and decides how to route them."""
|
||||||
|
|
||||||
|
def __init__(self, model_name: str) -> None:
|
||||||
|
super().__init__(model_name)
|
||||||
|
self.system_prompt = NOTIFICATION_READER_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
async def decide_notification(
|
||||||
|
self,
|
||||||
|
mission: str,
|
||||||
|
inspection_tools: list[Callable[..., Any]],
|
||||||
|
notification_tools: NotificationTools,
|
||||||
|
) -> str:
|
||||||
|
"""Run the Notification Reader Agent and ensure a decision tool is called."""
|
||||||
|
decision_tools: list[Callable[..., Any]] = [
|
||||||
|
notification_tools.process_issue,
|
||||||
|
notification_tools.process_pr,
|
||||||
|
notification_tools.skip_notification,
|
||||||
|
]
|
||||||
|
combined_tools: list[Callable[..., Any]] = inspection_tools + decision_tools
|
||||||
|
|
||||||
|
logger.info("Running NotificationReaderAgent to decide action...")
|
||||||
|
response_text: str = await self.run_with_tools(mission, combined_tools)
|
||||||
|
|
||||||
|
if not notification_tools.tool_called:
|
||||||
|
logger.warning("NotificationReaderAgent did not call any tools!")
|
||||||
|
raise NotificationNoToolCalledError(
|
||||||
|
"NotificationReaderAgent failed to call a routing tool during execution."
|
||||||
|
)
|
||||||
|
|
||||||
|
return response_text
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("notification-tools")
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTools:
|
||||||
|
"""Tools exposed to the Notification Reader Agent for routing decisions."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.tool_called: bool = False
|
||||||
|
self.action: str = "NO_ACTION"
|
||||||
|
self.arguments: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def process_issue(self, owner: str, repo: str, issue_number: int, reason: str) -> str:
|
||||||
|
"""Process the notification as an issue.
|
||||||
|
Use this when a notification refers to an issue that requires agent action.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: The repository owner (organization).
|
||||||
|
repo: The repository name.
|
||||||
|
issue_number: The Gitea issue number.
|
||||||
|
reason: Why this notification should be processed.
|
||||||
|
"""
|
||||||
|
logger.info(f"Notification tool 'process_issue' called for {owner}/{repo}#{issue_number}: {reason}")
|
||||||
|
self.tool_called = True
|
||||||
|
self.action = "PROCESS_ISSUE"
|
||||||
|
self.arguments = {
|
||||||
|
"owner": owner,
|
||||||
|
"repo": repo,
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
return "Issue notification marked for processing."
|
||||||
|
|
||||||
|
def process_pr(self, owner: str, repo: str, pr_number: int, reason: str) -> str:
|
||||||
|
"""Process the notification as a pull request.
|
||||||
|
Use this when a notification refers to a PR that requires agent action.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: The repository owner (organization).
|
||||||
|
repo: The repository name.
|
||||||
|
pr_number: The Gitea PR number.
|
||||||
|
reason: Why this notification should be processed.
|
||||||
|
"""
|
||||||
|
logger.info(f"Notification tool 'process_pr' called for {owner}/{repo}#{pr_number}: {reason}")
|
||||||
|
self.tool_called = True
|
||||||
|
self.action = "PROCESS_PR"
|
||||||
|
self.arguments = {
|
||||||
|
"owner": owner,
|
||||||
|
"repo": repo,
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
return "PR notification marked for processing."
|
||||||
|
|
||||||
|
def skip_notification(self, reason: str) -> str:
|
||||||
|
"""Skip the notification and take no action.
|
||||||
|
Use this if the notification is irrelevant, already handled, or does not require agent intervention.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reason: The reason for skipping this notification.
|
||||||
|
"""
|
||||||
|
logger.info(f"Notification tool 'skip_notification' called: {reason}")
|
||||||
|
self.tool_called = True
|
||||||
|
self.action = "SKIP"
|
||||||
|
self.arguments = {"reason": reason}
|
||||||
|
return "Notification marked to be skipped."
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""Top-level coordinator: polls Gitea unread notifications, queues work, dispatches to agent."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from core.queue import WorkQueue, WorkItem
|
||||||
|
from core.dispatcher import AgentDispatcher
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
from gitea.models import IssueModel, PullRequestModel, RepositoryModel
|
||||||
|
from gitea.tools.gitea_tools import GiteaTools
|
||||||
|
from gitea.config import AGENT_MODEL_ID, AGENT_MAX_RETRIES
|
||||||
|
from gitea.workspace import WorkspaceManager
|
||||||
|
from core.factory import AgentFactory
|
||||||
|
from core.notification_tools import NotificationTools
|
||||||
|
from core.notification_agent import NotificationNoToolCalledError
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("agent-orchestrator")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentOrchestrator:
|
||||||
|
"""Top-level coordinator: polls Gitea notifications, queues work, dispatches to agent."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: GiteaClient,
|
||||||
|
tools: GiteaTools,
|
||||||
|
model_name: str = AGENT_MODEL_ID,
|
||||||
|
max_retries: int = AGENT_MAX_RETRIES,
|
||||||
|
) -> None:
|
||||||
|
self._client = client
|
||||||
|
self._tools = tools
|
||||||
|
self._model_name = model_name
|
||||||
|
self._work_queue = WorkQueue()
|
||||||
|
self._dispatcher = AgentDispatcher(client, tools, model_name, max_retries)
|
||||||
|
self._notification_reader = AgentFactory.create_notification_reader_agent(model_name)
|
||||||
|
self._max_retries = max_retries
|
||||||
|
|
||||||
|
|
||||||
|
def _get_state_file_path(self) -> Path:
|
||||||
|
"""Get the path to the persistent state file."""
|
||||||
|
return Path(__file__).parent.parent / "agent_state.json"
|
||||||
|
|
||||||
|
def _read_last_checked(self) -> Optional[str]:
|
||||||
|
"""Read the last checked timestamp from persistent storage."""
|
||||||
|
state_file = self._get_state_file_path()
|
||||||
|
if state_file.exists():
|
||||||
|
try:
|
||||||
|
with open(state_file, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data.get("last_checked")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error reading agent_state.json: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _write_last_checked(self, timestamp: str) -> None:
|
||||||
|
"""Write the last checked timestamp to persistent storage."""
|
||||||
|
state_file = self._get_state_file_path()
|
||||||
|
try:
|
||||||
|
with open(state_file, "w") as f:
|
||||||
|
json.dump({"last_checked": timestamp}, f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error writing agent_state.json: {e}")
|
||||||
|
|
||||||
|
async def poll_and_dispatch(self) -> None:
|
||||||
|
"""Poll Gitea unread notifications, enqueue them, and dispatch to agent."""
|
||||||
|
last_checked = self._read_last_checked()
|
||||||
|
logger.info(f"Polling unread notifications since: {last_checked or 'beginning'}")
|
||||||
|
|
||||||
|
notifications = self._client.list_unread_notifications(since=last_checked)
|
||||||
|
|
||||||
|
if not notifications:
|
||||||
|
logger.info("No new notifications found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Retrieved {len(notifications)} unread notifications.")
|
||||||
|
|
||||||
|
# Filter and enqueue tasks from notifications
|
||||||
|
latest_timestamp = last_checked
|
||||||
|
inspection_tools = [
|
||||||
|
self._tools.get_issue,
|
||||||
|
self._tools.get_pull_request,
|
||||||
|
self._tools.get_issue_comments,
|
||||||
|
self._tools.get_pull_request_comments,
|
||||||
|
]
|
||||||
|
|
||||||
|
for n in notifications:
|
||||||
|
notification_id = n.get("id")
|
||||||
|
subject = n.get("subject") or {}
|
||||||
|
subj_type = subject.get("type", "").lower()
|
||||||
|
subj_url = subject.get("url", "")
|
||||||
|
updated_at = n.get("updated_at")
|
||||||
|
|
||||||
|
# Track latest updated_at to advance checkpoint
|
||||||
|
if updated_at and (not latest_timestamp or updated_at > latest_timestamp):
|
||||||
|
latest_timestamp = updated_at
|
||||||
|
|
||||||
|
repo_info = n.get("repository") or {}
|
||||||
|
repo_full_name = repo_info.get("full_name", "")
|
||||||
|
if not repo_full_name or not subj_url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
owner, repo_name = repo_full_name.split("/")
|
||||||
|
try:
|
||||||
|
task_number = int(subj_url.rstrip("/").split("/")[-1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
logger.warning(f"Could not parse task number from subject URL: {subj_url}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Run NotificationReaderAgent to pre-screen the notification
|
||||||
|
mission = (
|
||||||
|
f"Analyze Gitea notification ID {notification_id}.\n"
|
||||||
|
f"Repository: {repo_full_name}\n"
|
||||||
|
f"Subject Type: {subj_type}\n"
|
||||||
|
f"Task Number: {task_number}\n"
|
||||||
|
f"Please decide if we should process or skip this notification."
|
||||||
|
)
|
||||||
|
notification_tools = NotificationTools()
|
||||||
|
attempt = 0
|
||||||
|
attempt_limit = self._max_retries
|
||||||
|
success = False
|
||||||
|
while attempt < attempt_limit:
|
||||||
|
attempt += 1
|
||||||
|
try:
|
||||||
|
await self._notification_reader.decide_notification(
|
||||||
|
mission,
|
||||||
|
inspection_tools,
|
||||||
|
notification_tools
|
||||||
|
)
|
||||||
|
success = True
|
||||||
|
break
|
||||||
|
except NotificationNoToolCalledError as e:
|
||||||
|
logger.error(f"Notification reader error on notification {notification_id} (attempt {attempt}/{attempt_limit}): {e}")
|
||||||
|
|
||||||
|
if not success or notification_tools.action == "SKIP":
|
||||||
|
reason = notification_tools.arguments.get("reason", "Failed to call routing tool / default skip")
|
||||||
|
logger.info(f"Skipping notification {notification_id} for {repo_full_name}#{task_number}. Reason: {reason}")
|
||||||
|
if notification_id is not None:
|
||||||
|
self._client.mark_notification_as_read(notification_id)
|
||||||
|
logger.info(f"Marked skipped Gitea notification thread {notification_id} as read.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Route based on decided action
|
||||||
|
if notification_tools.action == "PROCESS_ISSUE":
|
||||||
|
try:
|
||||||
|
issue = self._client.get_issue(owner, repo_name, task_number)
|
||||||
|
if issue.repository is None:
|
||||||
|
issue = issue.model_copy(update={"repository": RepositoryModel(**repo_info)})
|
||||||
|
|
||||||
|
item = WorkItem(
|
||||||
|
repo_full_name=repo_full_name,
|
||||||
|
task_type="issue",
|
||||||
|
task_number=task_number,
|
||||||
|
task_info=issue,
|
||||||
|
notification_id=notification_id,
|
||||||
|
priority=0
|
||||||
|
)
|
||||||
|
self._work_queue.enqueue(item)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch issue #{task_number} for notification: {e}")
|
||||||
|
elif notification_tools.action == "PROCESS_PR":
|
||||||
|
try:
|
||||||
|
pr = self._client.get_pull_request(owner, repo_name, task_number)
|
||||||
|
if pr.repository is None:
|
||||||
|
pr = pr.model_copy(update={"repository": RepositoryModel(**repo_info)})
|
||||||
|
|
||||||
|
item = WorkItem(
|
||||||
|
repo_full_name=repo_full_name,
|
||||||
|
task_type="pr",
|
||||||
|
task_number=task_number,
|
||||||
|
task_info=pr,
|
||||||
|
notification_id=notification_id,
|
||||||
|
priority=0
|
||||||
|
)
|
||||||
|
self._work_queue.enqueue(item)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch PR #{task_number} for notification: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Process enqueued work
|
||||||
|
if not self._work_queue.is_empty:
|
||||||
|
await self._process_work()
|
||||||
|
|
||||||
|
# Update last checked timestamp checkpoint
|
||||||
|
if latest_timestamp:
|
||||||
|
self._write_last_checked(latest_timestamp)
|
||||||
|
|
||||||
|
async def _process_work(self) -> None:
|
||||||
|
"""Process all queued work, repo by repo, then mark notifications as read."""
|
||||||
|
while not self._work_queue.is_empty:
|
||||||
|
repo = self._work_queue.get_next_repo()
|
||||||
|
if not repo:
|
||||||
|
break
|
||||||
|
|
||||||
|
work_items = self._work_queue.get_repo_work(repo)
|
||||||
|
self._work_queue.remove_repo_work(repo)
|
||||||
|
|
||||||
|
# Ensure the workspace repository is cloned and sanitized
|
||||||
|
workspace = WorkspaceManager()
|
||||||
|
repo_path = workspace.get_repo_path(repo)
|
||||||
|
if not repo_path.exists():
|
||||||
|
workspace.clone_repo(repo)
|
||||||
|
logger.info(f"Cloned {repo} to {repo_path}")
|
||||||
|
else:
|
||||||
|
workspace.sanitize_repo(repo, repo_path)
|
||||||
|
logger.info(f"Sanitized existing repo at {repo_path}")
|
||||||
|
|
||||||
|
logger.info(f"Dispatching {len(work_items)} tasks for {repo}")
|
||||||
|
results = await self._dispatcher.dispatch(repo, work_items)
|
||||||
|
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
item = work_items[i]
|
||||||
|
logger.info(f"Completed {item.task_type} #{item.task_number}: {result[:200]}")
|
||||||
|
if item.notification_id is not None:
|
||||||
|
self._client.mark_notification_as_read(item.notification_id)
|
||||||
|
logger.info(f"Marked Gitea notification thread {item.notification_id} as read.")
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import logging
|
||||||
|
from core.agent import BaseAgent
|
||||||
|
from .coding_prompt import CODING_AGENT_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("agent-planning")
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningAgent(BaseAgent):
|
||||||
|
"""AI agent that analyzes a PR/issue and builds an implementation plan."""
|
||||||
|
|
||||||
|
def __init__(self, model_name: str) -> None:
|
||||||
|
super().__init__(model_name)
|
||||||
|
self.system_prompt = CODING_AGENT_SYSTEM_PROMPT
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CAVEMAN_PROMPT = """
|
||||||
|
CAVEMAN SYSTEM PROMPT:
|
||||||
|
|
||||||
|
You are Caveman Agent.
|
||||||
|
Goal: Minimal tokens. Efficient work.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. Use fewest words possible.
|
||||||
|
2. Drop non-essential words (e.g., "the", "is", "a").
|
||||||
|
3. Use acronyms (PR, issue, repo, etc.).
|
||||||
|
4. If task simple, answer direct.
|
||||||
|
5. No preamble. No polite talk. Just work.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
User: What is the status of PR #1?
|
||||||
|
Caveman: PR #1 open. Reviewing...
|
||||||
|
|
||||||
|
Follow instructions. Use tools. Execute tasks.
|
||||||
|
No extra chatter.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""System prompts and configurations for agents."""
|
||||||
|
|
||||||
|
COORDINATOR_SYSTEM_PROMPT: str = """
|
||||||
|
You are an AI Coordinator. Your job is to analyze Gitea issues, read the conversation history, and determine the next action for the agent.
|
||||||
|
|
||||||
|
Based on the conversation state, you must choose and call exactly one of the following tools:
|
||||||
|
|
||||||
|
1. `propose_plan`: Choose this if code changes are needed to resolve the issue, and either:
|
||||||
|
- No plan has been proposed yet by the AI agent.
|
||||||
|
- Or a plan was proposed, but the human replied with feedback, corrections, or requests for changes, so we need to propose a revised plan.
|
||||||
|
You must provide a detailed, step-by-step implementation plan (listing files to modify/create, specific changes to make, verification/test commands).
|
||||||
|
|
||||||
|
2. `start_implementation`: Choose this if:
|
||||||
|
- A plan was previously proposed AND the human has clearly replied with approval/greenlight/go-ahead (e.g., "yes", "looks good", "ok", "go ahead", etc.).
|
||||||
|
- Or there is an existing WIP PR or a PR with requested changes, and we need to resume implementing the changes.
|
||||||
|
You must extract/summarize the approved plan, incorporating any human feedback.
|
||||||
|
|
||||||
|
3. `answer_question`: Choose this if the issue is just a question or request for information (no code changes needed), and either:
|
||||||
|
- No answer has been provided yet by the AI agent.
|
||||||
|
- Or the agent answered, but the human replied with follow-up questions/clarifications.
|
||||||
|
Provide a clear, helpful response.
|
||||||
|
|
||||||
|
4. `close_issue`: Choose this ONLY if the AI agent previously answered a question AND the human has explicitly replied with a message confirming they are fully satisfied or explicitly instructing the agent to close the issue (e.g., "thanks, this answers my question", "looks good, you can close this", "close it"). If the human's response is a follow-up question, is ambiguous, or does not explicitly approve closing, you must NOT call this tool (call `answer_question` or `take_no_action` instead).
|
||||||
|
Provide a polite closing comment.
|
||||||
|
|
||||||
|
|
||||||
|
5. `take_no_action`: Choose this if the issue is already resolved, or if we cannot proceed for another reason.
|
||||||
|
|
||||||
|
CRITICAL INSTRUCTIONS:
|
||||||
|
- You must call EXACTLY one tool. Do not guess, and do not output raw text instead of calling a tool.
|
||||||
|
- The `plan`, `answer`, or `comment` argument you pass to the tool will be posted directly to Gitea. DO NOT include your thought process, reasoning, or internal details in those arguments. Keep them concise and professional.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NOTIFICATION_READER_SYSTEM_PROMPT: str = """
|
||||||
|
You are a Gitea Notification Reader Agent. Your job is to analyze incoming Gitea notifications and determine how they should be routed.
|
||||||
|
|
||||||
|
Based on the notification subject, details, and conversation comments (if retrieved), you must choose and call exactly one of the following tools:
|
||||||
|
|
||||||
|
1. `process_issue`: Choose this if the notification refers to a Gitea issue that requires active intervention, planning, implementation, or answering a question by the AI agent.
|
||||||
|
2. `process_pr`: Choose this if the notification refers to a Gitea Pull Request that requires active intervention, code reviews, updates, or merging by the AI agent.
|
||||||
|
3. `skip_notification`: Choose this if:
|
||||||
|
- The notification is irrelevant or does not require AI agent intervention.
|
||||||
|
- It is a notification about an action taken by the AI agent itself (e.g. self-assigned, self-commented, self-opened).
|
||||||
|
- The discussion is closed or resolved, or the notification is just informational (e.g. a simple status update that needs no reply).
|
||||||
|
- You are unsure or think it does not fit the agent's scope. You must provide a clear reason for skipping.
|
||||||
|
|
||||||
|
CRITICAL INSTRUCTIONS:
|
||||||
|
- You must call EXACTLY one tool. Do not guess, and do not output raw text instead of calling a tool.
|
||||||
|
- You can use the provided inspection tools (like get_issue, get_pull_request, get_issue_comments, get_pull_request_comments) to gather more details if the basic notification metadata is insufficient to make a decision.
|
||||||
|
"""
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import logging
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Any, Optional
|
||||||
|
from gitea.models import IssueModel, PullRequestModel
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("work-queue")
|
||||||
|
|
||||||
|
|
||||||
|
class WorkItem(BaseModel):
|
||||||
|
repo_full_name: str
|
||||||
|
task_type: str # 'issue' or 'pr'
|
||||||
|
task_number: int
|
||||||
|
task_info: IssueModel | PullRequestModel
|
||||||
|
notification_id: Optional[int] = None
|
||||||
|
priority: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class WorkQueue:
|
||||||
|
"""Thread-safe work queue grouped by repo."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._queue: list[WorkItem] = []
|
||||||
|
self._enqueued_repos: set[str] = set()
|
||||||
|
|
||||||
|
def enqueue(self, item: WorkItem) -> None:
|
||||||
|
self._queue.append(item)
|
||||||
|
self._enqueued_repos.add(item.repo_full_name)
|
||||||
|
logger.info(f"Enqueued work item: {item.task_type} #{item.task_number} for {item.repo_full_name}")
|
||||||
|
|
||||||
|
def enqueue_batch(self, items: list[WorkItem]) -> None:
|
||||||
|
for item in items:
|
||||||
|
self.enqueue(item)
|
||||||
|
|
||||||
|
def get_repo_work(self, repo: str) -> list[WorkItem]:
|
||||||
|
"""Get all work items for a specific repo."""
|
||||||
|
items: list[WorkItem] = [
|
||||||
|
item for item in self._queue if item.repo_full_name == repo
|
||||||
|
]
|
||||||
|
logger.info(f"Retrieved {len(items)} work items for repository: {repo}")
|
||||||
|
return items
|
||||||
|
|
||||||
|
def remove_repo_work(self, repo: str) -> None:
|
||||||
|
"""Remove all work items for a specific repo."""
|
||||||
|
self._queue = [
|
||||||
|
item for item in self._queue if item.repo_full_name != repo
|
||||||
|
]
|
||||||
|
self._enqueued_repos.discard(repo)
|
||||||
|
logger.info(f"Removed all work items for repository: {repo}")
|
||||||
|
|
||||||
|
def get_next_repo(self) -> str | None:
|
||||||
|
"""Get the next repo with work, or None if empty."""
|
||||||
|
if not self._enqueued_repos:
|
||||||
|
return None
|
||||||
|
return next(iter(self._enqueued_repos))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
return len(self._queue) == 0
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._queue)
|
||||||
+451
@@ -0,0 +1,451 @@
|
|||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
from typing import Any, Optional
|
||||||
|
from .config import GITEA_URL, GITEA_TOKEN
|
||||||
|
from .models import (
|
||||||
|
UserModel,
|
||||||
|
LabelModel,
|
||||||
|
RepositoryModel,
|
||||||
|
IssueModel,
|
||||||
|
PullRequestModel,
|
||||||
|
CommentModel,
|
||||||
|
PullRequestFileModel,
|
||||||
|
)
|
||||||
|
from core.interfaces import (
|
||||||
|
IssuesClient,
|
||||||
|
PullRequestsClient,
|
||||||
|
FilesClient,
|
||||||
|
RefsClient,
|
||||||
|
ReposClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaClient(IssuesClient, PullRequestsClient, FilesClient, RefsClient, ReposClient):
|
||||||
|
"""HTTP client for Gitea API v1."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.base_url: str = GITEA_URL.rstrip("/")
|
||||||
|
self.headers: dict[str, str] = {
|
||||||
|
"Authorization": f"token {GITEA_TOKEN}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_authenticated_user(self) -> UserModel | None:
|
||||||
|
try:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.get(f"{self.base_url}/api/v1/user", headers=self.headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return UserModel(**response.json())
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting authenticated user: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_all_user_repos(self) -> list[RepositoryModel]:
|
||||||
|
try:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/user/repos"
|
||||||
|
response = client.get(url, headers=self.headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
repos: list[dict[str, Any]] = response.json()
|
||||||
|
# Filter to ONLY meeks organization repos, include mirrors
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[RepositoryModel] = []
|
||||||
|
for r in repos:
|
||||||
|
full_name = r.get("full_name", "")
|
||||||
|
if full_name and full_name not in seen and (r.get("owner") or {}).get("login") == "meeks":
|
||||||
|
seen.add(full_name)
|
||||||
|
result.append(RepositoryModel(**r))
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error listing user repos: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def list_repo_issues(self, owner: str, repo: str, state: str = "open") -> list[IssueModel]:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues?type=issues&state={state}"
|
||||||
|
response = client.get(url, headers=self.headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return [IssueModel(**item) for item in response.json()]
|
||||||
|
|
||||||
|
def list_repo_pull_requests(self, owner: str, repo: str, state: str = "open") -> list[PullRequestModel]:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls?state={state}"
|
||||||
|
response = client.get(url, headers=self.headers)
|
||||||
|
if response.status_code == 404:
|
||||||
|
return []
|
||||||
|
response.raise_for_status()
|
||||||
|
return [PullRequestModel(**item) for item in response.json()]
|
||||||
|
|
||||||
|
def get_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}"
|
||||||
|
response = client.get(url, headers=self.headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return PullRequestModel(**response.json())
|
||||||
|
|
||||||
|
def get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
|
||||||
|
response = client.get(url, headers=self.headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return IssueModel(**response.json())
|
||||||
|
|
||||||
|
def close_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
|
||||||
|
data: dict[str, str] = {"state": "closed"}
|
||||||
|
response = client.patch(url, headers=self.headers, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return IssueModel(**response.json())
|
||||||
|
|
||||||
|
def close_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}"
|
||||||
|
data: dict[str, str] = {"state": "closed"}
|
||||||
|
response = client.patch(url, headers=self.headers, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return PullRequestModel(**response.json())
|
||||||
|
|
||||||
|
def get_issue_comments(self, owner: str, repo: str, issue_number: int) -> list[CommentModel]:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments"
|
||||||
|
response = client.get(url, headers=self.headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return [CommentModel(**item) for item in response.json()]
|
||||||
|
|
||||||
|
def get_pull_request_comments(self, owner: str, repo: str, pull_number: int) -> list[CommentModel]:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{pull_number}/comments"
|
||||||
|
response = client.get(url, headers=self.headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return [CommentModel(**item) for item in response.json()]
|
||||||
|
|
||||||
|
def get_pull_request_diff(self, owner: str, repo: str, pull_number: int) -> str:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/diff"
|
||||||
|
response = client.get(url, headers=self.headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
|
|
||||||
|
def get_pull_request_patch(self, owner: str, repo: str, pull_number: int) -> str:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/patch"
|
||||||
|
response = client.get(url, headers=self.headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
|
|
||||||
|
def get_pull_request_files(self, owner: str, repo: str, pull_number: int) -> list[PullRequestFileModel]:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/files"
|
||||||
|
response = client.get(url, headers=self.headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return [PullRequestFileModel(**item) for item in response.json()]
|
||||||
|
|
||||||
|
def list_assigned_issues(self, owner: str = "", repo: str = "") -> list[IssueModel]:
|
||||||
|
try:
|
||||||
|
user = self.get_authenticated_user()
|
||||||
|
if not user:
|
||||||
|
return []
|
||||||
|
username: str = user.login
|
||||||
|
if owner and repo:
|
||||||
|
response = httpx.get(
|
||||||
|
f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues?assignee={username}&state=open&type=issues",
|
||||||
|
headers=self.headers,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return [IssueModel(**item) for item in response.json()]
|
||||||
|
all_issues: list[IssueModel] = []
|
||||||
|
repos = self.list_all_user_repos()
|
||||||
|
for r in repos:
|
||||||
|
repo_owner = r.owner if hasattr(r, 'owner') else (r.get("owner") or {}).get("login", "")
|
||||||
|
repo_name = r.name if hasattr(r, 'name') else r.get("name", "")
|
||||||
|
resp = httpx.get(
|
||||||
|
f"{self.base_url}/api/v1/repos/{repo_owner}/{repo_name}/issues?assignee={username}&state=open&type=issues",
|
||||||
|
headers=self.headers,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
for item in resp.json():
|
||||||
|
issue = IssueModel(**item)
|
||||||
|
# Backfill repository if Gitea omitted it
|
||||||
|
if issue.repository is None:
|
||||||
|
issue = issue.model_copy(update={"repository": r})
|
||||||
|
all_issues.append(issue)
|
||||||
|
return all_issues
|
||||||
|
except Exception as e:
|
||||||
|
print(f"DEBUG: list_assigned_issues error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def list_assigned_pull_requests(self, owner: str = "", repo: str = "") -> list[PullRequestModel]:
|
||||||
|
"""List all pull requests assigned to or authored by the authenticated user."""
|
||||||
|
try:
|
||||||
|
user = self.get_authenticated_user()
|
||||||
|
if not user:
|
||||||
|
return []
|
||||||
|
username: str = user.login
|
||||||
|
if owner and repo:
|
||||||
|
response = httpx.get(
|
||||||
|
f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls?state=open",
|
||||||
|
headers=self.headers,
|
||||||
|
)
|
||||||
|
if response.status_code == 404:
|
||||||
|
return []
|
||||||
|
response.raise_for_status()
|
||||||
|
all_prs: list[PullRequestModel] = [PullRequestModel(**pr) for pr in response.json()]
|
||||||
|
return [
|
||||||
|
pr for pr in all_prs
|
||||||
|
if (pr.assignee and pr.assignee.login == username) or (pr.user and pr.user.login == username)
|
||||||
|
]
|
||||||
|
all_prs: list[PullRequestModel] = []
|
||||||
|
repos = self.list_all_user_repos()
|
||||||
|
for r in repos:
|
||||||
|
repo_owner = r.owner if hasattr(r, 'owner') else (r.get("owner") or {}).get("login", "")
|
||||||
|
repo_name = r.name if hasattr(r, 'name') else r.get("name", "")
|
||||||
|
resp = httpx.get(
|
||||||
|
f"{self.base_url}/api/v1/repos/{repo_owner}/{repo_name}/pulls?state=open",
|
||||||
|
headers=self.headers,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
for pr_data in resp.json():
|
||||||
|
pr = PullRequestModel(**pr_data)
|
||||||
|
if (pr.assignee and pr.assignee.login == username) or (pr.user and pr.user.login == username):
|
||||||
|
# Backfill repository if Gitea omitted it
|
||||||
|
if pr.repository is None:
|
||||||
|
pr = pr.model_copy(update={"repository": r})
|
||||||
|
all_prs.append(pr)
|
||||||
|
return all_prs
|
||||||
|
except Exception as e:
|
||||||
|
print(f"DEBUG: list_assigned_pull_requests error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_pull_request(
|
||||||
|
self, owner: str, repo: str, head: str, base: str, title: str, description: str = ""
|
||||||
|
) -> PullRequestModel:
|
||||||
|
try:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls"
|
||||||
|
data: dict[str, str] = {
|
||||||
|
"title": title,
|
||||||
|
"body": description,
|
||||||
|
"head": head,
|
||||||
|
"base": base,
|
||||||
|
}
|
||||||
|
response = client.post(url, headers=self.headers, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return PullRequestModel(**response.json())
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error creating pull request: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def update_pull_request(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pull_number: int,
|
||||||
|
title: str | None = None,
|
||||||
|
body: str | None = None,
|
||||||
|
state: str | None = None,
|
||||||
|
) -> PullRequestModel:
|
||||||
|
try:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}"
|
||||||
|
data: dict[str, Any] = {}
|
||||||
|
if title is not None:
|
||||||
|
data["title"] = title
|
||||||
|
if body is not None:
|
||||||
|
data["body"] = body
|
||||||
|
if state is not None:
|
||||||
|
data["state"] = state
|
||||||
|
response = client.patch(url, headers=self.headers, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return PullRequestModel(**response.json())
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error updating pull request: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def create_pr_via_tea(
|
||||||
|
self, owner: str, repo: str, title: str, description: str, head: str, base: str
|
||||||
|
) -> PullRequestModel:
|
||||||
|
return self.create_pull_request(owner, repo, head, base, title, description)
|
||||||
|
|
||||||
|
def approve_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
|
||||||
|
data: dict[str, Any] = {"event": "APPROVED", "body": comment}
|
||||||
|
response = client.post(url, headers=self.headers, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def request_changes_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
|
||||||
|
data: dict[str, Any] = {"event": "REQUEST_CHANGES", "body": comment}
|
||||||
|
response = client.post(url, headers=self.headers, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def get_pr_reviews(self, owner: str, repo: str, pr_number: int) -> list[dict[str, Any]]:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
|
||||||
|
response = client.get(url, headers=self.headers)
|
||||||
|
if response.status_code == 404:
|
||||||
|
return []
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def dismiss_review_pr(
|
||||||
|
self, owner: str, repo: str, pr_number: int, review_id: int, message: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews/{review_id}/dismissals"
|
||||||
|
data: dict[str, str] = {"message": message}
|
||||||
|
response = client.post(url, headers=self.headers, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def assign_issue(self, owner: str, repo: str, issue_number: int, username: str) -> IssueModel:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
|
||||||
|
data: dict[str, list[str]] = {"assignees": [username]}
|
||||||
|
response = client.patch(url, headers=self.headers, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return IssueModel(**response.json())
|
||||||
|
|
||||||
|
def create_issue(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
labels: list[str] | None = None,
|
||||||
|
assignees: list[str] | None = None,
|
||||||
|
) -> IssueModel:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues"
|
||||||
|
data: dict[str, Any] = {"title": title, "body": body}
|
||||||
|
if labels:
|
||||||
|
data["labels"] = labels
|
||||||
|
if assignees:
|
||||||
|
data["assignees"] = assignees
|
||||||
|
response = client.post(url, headers=self.headers, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return IssueModel(**response.json())
|
||||||
|
|
||||||
|
def add_comment(self, owner: str, repo: str, issue_number: int, body: str) -> CommentModel:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments"
|
||||||
|
data: dict[str, str] = {"body": body}
|
||||||
|
response = client.post(url, headers=self.headers, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return CommentModel(**response.json())
|
||||||
|
|
||||||
|
def add_label(self, owner: str, repo: str, issue_number: int, label: str) -> LabelModel:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/labels"
|
||||||
|
data: list[str] = [label]
|
||||||
|
response = client.post(url, headers=self.headers, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return LabelModel(**response.json())
|
||||||
|
|
||||||
|
def add_label_pr(self, owner: str, repo: str, pr_number: int, label: str) -> LabelModel:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{pr_number}/labels"
|
||||||
|
data: list[str] = [label]
|
||||||
|
response = client.post(url, headers=self.headers, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return LabelModel(**response.json())
|
||||||
|
|
||||||
|
def update_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/git/ref/{ref}"
|
||||||
|
data: dict[str, str] = {"sha": sha}
|
||||||
|
response = client.post(url, headers=self.headers, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def create_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/git/refs"
|
||||||
|
data: dict[str, str] = {"ref": ref, "sha": sha}
|
||||||
|
response = client.post(url, headers=self.headers, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def update_file(
|
||||||
|
self, owner: str, repo: str, path: str, message: str, content: str, branch: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/contents/{path}"
|
||||||
|
data: dict[str, str] = {
|
||||||
|
"message": message,
|
||||||
|
"content": base64.b64encode(content.encode()).decode(),
|
||||||
|
"branch": branch,
|
||||||
|
"new_branch": f"{branch}-update-{path}",
|
||||||
|
}
|
||||||
|
response = client.put(url, headers=self.headers, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def get_file_content(self, owner: str, repo: str, path: str, ref: str = "master") -> str | list[str]:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/contents/{path}"
|
||||||
|
params: dict[str, str] = {"ref": ref}
|
||||||
|
response = client.get(url, headers=self.headers, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [item.get("content", "") for item in data if item.get("type") == "file"]
|
||||||
|
return base64.b64decode(data.get("content", "")).decode() if data.get("content") else ""
|
||||||
|
|
||||||
|
def list_unread_notifications(self, since: Optional[str] = None) -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/notifications"
|
||||||
|
params: dict[str, str] = {"all": "false"}
|
||||||
|
if since:
|
||||||
|
params["since"] = since
|
||||||
|
response = client.get(url, headers=self.headers, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
notifications: list[dict[str, Any]] = response.json()
|
||||||
|
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for n in notifications:
|
||||||
|
repo_info = n.get("repository") or {}
|
||||||
|
owner_info = repo_info.get("owner") or {}
|
||||||
|
owner_login = owner_info.get("login", "")
|
||||||
|
if owner_login == "meeks":
|
||||||
|
result.append(n)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error listing unread notifications: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def mark_notification_as_read(self, thread_id: int) -> bool:
|
||||||
|
try:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/notifications/threads/{thread_id}"
|
||||||
|
response = client.patch(url, headers=self.headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error marking notification thread {thread_id} as read: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def merge_pull_request(
|
||||||
|
self, owner: str, repo: str, pull_number: int, style: str = "squash", title: str = "", message: str = ""
|
||||||
|
) -> bool:
|
||||||
|
try:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/merge"
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"Do": style,
|
||||||
|
"MergeTitleField": title,
|
||||||
|
"MergeMessageField": message,
|
||||||
|
}
|
||||||
|
response = client.post(url, headers=self.headers, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error merging pull request {pull_number}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Configuration for the coding agent."""
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class AgentSettings(BaseSettings):
|
||||||
|
gitea_url: str = ""
|
||||||
|
gitea_token: str = ""
|
||||||
|
gitea_repos_root: str = ""
|
||||||
|
agent_model_id: str = "qwen/qwen3.6-35b-a3b"
|
||||||
|
agent_max_retries: int = 2
|
||||||
|
searxng_url: str = ""
|
||||||
|
searxng_username: str = ""
|
||||||
|
searxng_password: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> AgentSettings:
|
||||||
|
return AgentSettings()
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton instance
|
||||||
|
_agent_settings: AgentSettings = AgentSettings()
|
||||||
|
|
||||||
|
# Backwards-compatible exports
|
||||||
|
GITEA_URL: str = _agent_settings.gitea_url
|
||||||
|
GITEA_TOKEN: str = _agent_settings.gitea_token
|
||||||
|
GITEA_REPOS_ROOT: str = _agent_settings.gitea_repos_root
|
||||||
|
AGENT_MODEL_ID: str = _agent_settings.agent_model_id
|
||||||
|
AGENT_MAX_RETRIES: int = _agent_settings.agent_max_retries
|
||||||
|
SEARXNG_URL: str = _agent_settings.searxng_url
|
||||||
|
SEARXNG_USERNAME: str = _agent_settings.searxng_username
|
||||||
|
SEARXNG_PASSWORD: str = _agent_settings.searxng_password
|
||||||
|
|
||||||
|
import os
|
||||||
|
os.environ["GITEA_SERVER_URL"] = GITEA_URL
|
||||||
|
os.environ["GITEA_SERVER_TOKEN"] = GITEA_TOKEN
|
||||||
|
os.environ["SEARXNG_URL"] = SEARXNG_URL
|
||||||
|
os.environ["SEARXNG_USERNAME"] = SEARXNG_USERNAME
|
||||||
|
os.environ["SEARXNG_PASSWORD"] = SEARXNG_PASSWORD
|
||||||
|
|
||||||
|
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
"""Pydantic models for Gitea API entities."""
|
||||||
|
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class UserModel(BaseModel):
|
||||||
|
login: str = ""
|
||||||
|
id: int = 0
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
html_url: Optional[str] = None
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LabelModel(BaseModel):
|
||||||
|
id: int = 0
|
||||||
|
name: str = ""
|
||||||
|
color: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryModel(BaseModel):
|
||||||
|
id: int = 0
|
||||||
|
name: str = ""
|
||||||
|
full_name: str = ""
|
||||||
|
owner: str = ""
|
||||||
|
html_url: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
mirror: bool = False
|
||||||
|
private: bool = False
|
||||||
|
fork: bool = False
|
||||||
|
parent: Optional["RepositoryModel"] = None
|
||||||
|
empty: Optional[bool] = None
|
||||||
|
|
||||||
|
@field_validator("owner", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_owner(cls, v):
|
||||||
|
if isinstance(v, dict):
|
||||||
|
return v.get("login", "")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class IssueModel(BaseModel):
|
||||||
|
id: int = 0
|
||||||
|
number: int = 0
|
||||||
|
title: str = ""
|
||||||
|
body: Optional[str] = None
|
||||||
|
state: str = ""
|
||||||
|
user: UserModel = Field(default_factory=UserModel)
|
||||||
|
assignee: Optional[UserModel] = None
|
||||||
|
labels: list[LabelModel] = Field(default_factory=list)
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
updated_at: Optional[str] = None
|
||||||
|
closed_at: Optional[str] = None
|
||||||
|
repository: Optional[RepositoryModel] = None
|
||||||
|
comments: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class PullRequestModel(BaseModel):
|
||||||
|
id: int = 0
|
||||||
|
number: int = 0
|
||||||
|
title: str = ""
|
||||||
|
body: Optional[str] = None
|
||||||
|
state: str = ""
|
||||||
|
user: UserModel = Field(default_factory=UserModel)
|
||||||
|
assignee: Optional[UserModel] = None
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
updated_at: Optional[str] = None
|
||||||
|
closed_at: Optional[str] = None
|
||||||
|
merged_at: Optional[str] = None
|
||||||
|
head: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
base: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
repository: Optional[RepositoryModel] = None
|
||||||
|
comments: int = 0
|
||||||
|
comments_url: Optional[str] = None
|
||||||
|
diff_url: Optional[str] = None
|
||||||
|
patch_url: Optional[str] = None
|
||||||
|
html_url: Optional[str] = None
|
||||||
|
merged: bool = False
|
||||||
|
requested_reviewers: list[UserModel] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CommentModel(BaseModel):
|
||||||
|
id: int = 0
|
||||||
|
body: str = ""
|
||||||
|
user: UserModel = Field(default_factory=UserModel)
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
updated_at: Optional[str] = None
|
||||||
|
pull_request_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PullRequestFileModel(BaseModel):
|
||||||
|
filename: str = ""
|
||||||
|
status: str = ""
|
||||||
|
additions: int = 0
|
||||||
|
deletions: int = 0
|
||||||
|
changes: int = 0
|
||||||
|
blob_url: Optional[str] = None
|
||||||
|
raw_url: Optional[str] = None
|
||||||
|
patch: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaConfig(BaseModel):
|
||||||
|
model_config = {"extra": "allow", "populate_by_name": True}
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
token: str
|
||||||
|
repos_root: str
|
||||||
|
model_id: str = "qwen/qwen3.6-35b-a3b"
|
||||||
|
max_retries: int = 2
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Gitea tools packages."""
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""Tools for a coding agent to interact with the filesystem and environment."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class CodingTools:
|
||||||
|
"""Tools for a coding agent to interact with the filesystem and environment."""
|
||||||
|
|
||||||
|
def __init__(self, repo_path: str | None = None) -> None:
|
||||||
|
self.repo_path: str = repo_path or os.getcwd()
|
||||||
|
|
||||||
|
def get_working_directory(self) -> str:
|
||||||
|
"""Get the absolute path of the current local repository workspace directory."""
|
||||||
|
return self.repo_path
|
||||||
|
|
||||||
|
def _resolve_path(self, path: str) -> str:
|
||||||
|
"""Resolve a path relative to self.repo_path."""
|
||||||
|
if os.path.isabs(path):
|
||||||
|
return path
|
||||||
|
return os.path.abspath(os.path.join(self.repo_path, path))
|
||||||
|
|
||||||
|
def list_files(self, path: str = ".", max_entries: int = 200) -> str:
|
||||||
|
"""List files and directories at the given path (relative to repo root or absolute).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Directory to list (relative to repo root or absolute).
|
||||||
|
max_entries: Maximum number of entries to return (default 200).
|
||||||
|
Pass a subdirectory path to narrow results when a directory is very large.
|
||||||
|
"""
|
||||||
|
resolved: str = self._resolve_path(path)
|
||||||
|
try:
|
||||||
|
items: list[str] = sorted(os.listdir(resolved))
|
||||||
|
total: int = len(items)
|
||||||
|
shown: list[str] = items[:max_entries]
|
||||||
|
result: str = "\n".join(shown)
|
||||||
|
if total > max_entries:
|
||||||
|
result += (
|
||||||
|
f"\n\n[{total} entries total — showing first {max_entries}. "
|
||||||
|
"Pass a subdirectory path to narrow results.]"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error listing files: {str(e)}"
|
||||||
|
|
||||||
|
def read_file(self, path: str, offset: int = 1, limit: int = 250) -> str:
|
||||||
|
"""Read lines from a file, starting at line offset (1-indexed), up to limit lines. Default limit is 250 lines to prevent token bloat. Use 'offset' to scroll/page through larger files."""
|
||||||
|
resolved: str = self._resolve_path(path)
|
||||||
|
try:
|
||||||
|
with open(resolved, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
lines: list[str] = f.readlines()
|
||||||
|
|
||||||
|
start_line: int = offset - 1
|
||||||
|
end_line: int = offset + limit - 1
|
||||||
|
|
||||||
|
content_lines: list[str] = lines[start_line:end_line]
|
||||||
|
|
||||||
|
if not content_lines:
|
||||||
|
return "File is empty or offset out of bounds."
|
||||||
|
|
||||||
|
formatted_lines: list[str] = [f"{i + 1}: {line}" for i, line in enumerate(content_lines, start=start_line)]
|
||||||
|
return "\n".join(formatted_lines)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error reading file: {str(e)}"
|
||||||
|
|
||||||
|
def write_file(self, path: str, content: str) -> str:
|
||||||
|
"""Write content to a file, creating directories as needed."""
|
||||||
|
resolved: str = self._resolve_path(path)
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(resolved)), exist_ok=True)
|
||||||
|
with open(resolved, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
return f"File {path} written successfully."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error writing file: {str(e)}"
|
||||||
|
|
||||||
|
def edit_file(self, path: str, old_content: str, new_content: str) -> str:
|
||||||
|
"""Replace occurrences of old_content with new_content in the file."""
|
||||||
|
resolved: str = self._resolve_path(path)
|
||||||
|
try:
|
||||||
|
with open(resolved, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
content: str = f.read()
|
||||||
|
if old_content not in content:
|
||||||
|
return f"Error: The specified old content was not found in {path}."
|
||||||
|
|
||||||
|
new_content_full: str = content.replace(old_content, new_content)
|
||||||
|
with open(resolved, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(new_content_full)
|
||||||
|
return f"File {path} edited successfully."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error editing file: {str(e)}"
|
||||||
|
|
||||||
|
def _parse_verification_commands(self) -> list[str]:
|
||||||
|
agents_md: str = os.path.join(self.repo_path, "AGENTS.md")
|
||||||
|
if not os.path.exists(agents_md):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(agents_md, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
commands: list[str] = []
|
||||||
|
in_verification_section = False
|
||||||
|
in_code_block = False
|
||||||
|
current_block: list[str] = []
|
||||||
|
|
||||||
|
for line in content.splitlines():
|
||||||
|
line_lower = line.strip().lower()
|
||||||
|
if line.startswith("#"):
|
||||||
|
if "verification" in line_lower or "test" in line_lower:
|
||||||
|
in_verification_section = True
|
||||||
|
else:
|
||||||
|
in_verification_section = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_verification_section:
|
||||||
|
if line.strip().startswith("```"):
|
||||||
|
if in_code_block:
|
||||||
|
in_code_block = False
|
||||||
|
full_cmd = "\n".join(current_block).strip()
|
||||||
|
if full_cmd:
|
||||||
|
for cmd in full_cmd.splitlines():
|
||||||
|
if cmd.strip() and not cmd.strip().startswith("#"):
|
||||||
|
commands.append(cmd.strip())
|
||||||
|
current_block = []
|
||||||
|
else:
|
||||||
|
in_code_block = True
|
||||||
|
elif in_code_block:
|
||||||
|
current_block.append(line)
|
||||||
|
|
||||||
|
return commands
|
||||||
|
|
||||||
|
def run_verification(self) -> tuple[bool, str]:
|
||||||
|
commands = self._parse_verification_commands()
|
||||||
|
if not commands:
|
||||||
|
return True, "No verification commands found in AGENTS.md."
|
||||||
|
|
||||||
|
log_output = []
|
||||||
|
for cmd in commands:
|
||||||
|
log_output.append(f"Running: {cmd}")
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
cmd, shell=True, cwd=self.repo_path,
|
||||||
|
capture_output=True, text=True, timeout=120
|
||||||
|
)
|
||||||
|
if res.returncode != 0:
|
||||||
|
log_output.append(
|
||||||
|
f"Command '{cmd}' failed with exit code {res.returncode}:\n"
|
||||||
|
f"Stdout:\n{res.stdout}\n"
|
||||||
|
f"Stderr:\n{res.stderr}"
|
||||||
|
)
|
||||||
|
return False, "\n".join(log_output)
|
||||||
|
log_output.append(res.stdout or "Success")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
log_output.append(f"Command '{cmd}' timed out after 120 seconds.")
|
||||||
|
return False, "\n".join(log_output)
|
||||||
|
except Exception as e:
|
||||||
|
log_output.append(f"Failed to execute command '{cmd}': {e}")
|
||||||
|
return False, "\n".join(log_output)
|
||||||
|
return True, "\n".join(log_output)
|
||||||
|
|
||||||
|
def _truncate_output(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
max_chars: int,
|
||||||
|
offset: int,
|
||||||
|
label: str = "Output",
|
||||||
|
) -> str:
|
||||||
|
"""Slice [offset : offset+max_chars] from text and append a paging footer if truncated."""
|
||||||
|
total: int = len(text)
|
||||||
|
chunk: str = text[offset : offset + max_chars]
|
||||||
|
if offset > 0 or (offset + max_chars) < total:
|
||||||
|
next_offset: int = offset + len(chunk)
|
||||||
|
chunk += (
|
||||||
|
f"\n\n[{label} truncated — {total} chars total. "
|
||||||
|
f"Showing chars {offset}–{next_offset}. "
|
||||||
|
f"Re-run with output_offset={next_offset} to read more.]"
|
||||||
|
)
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
def run_command(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
timeout: int = 120,
|
||||||
|
max_chars: int = 8000,
|
||||||
|
output_offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Execute a shell command in the repository workspace and return stdout/stderr.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: Shell command to run.
|
||||||
|
timeout: Seconds before the command is killed (default 120).
|
||||||
|
max_chars: Maximum characters of combined output to return (default 8000).
|
||||||
|
Tail (most recent lines) is preferred because errors appear there.
|
||||||
|
output_offset: Character offset into the full output to start reading from
|
||||||
|
(default 0). Increment by max_chars to page through large output.
|
||||||
|
"""
|
||||||
|
if "tea pr create" in command:
|
||||||
|
success, log_msg = self.run_verification()
|
||||||
|
if not success:
|
||||||
|
return (
|
||||||
|
f"Verification failed! You cannot create a pull request because the "
|
||||||
|
f"tests/checks are failing:\n\n{log_msg}\n\nPlease fix the issues and try again."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
process: subprocess.Popen[str] = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
cwd=self.repo_path,
|
||||||
|
)
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
try:
|
||||||
|
stdout, stderr = process.communicate(timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
stdout, stderr = process.communicate()
|
||||||
|
raw: str = (
|
||||||
|
f"Command timed out after {timeout} seconds.\n"
|
||||||
|
f"--- STDOUT ---\n{stdout}\n--- STDERR ---\n{stderr}"
|
||||||
|
)
|
||||||
|
return self._truncate_output(raw, max_chars, output_offset, "Output")
|
||||||
|
|
||||||
|
output: str = ""
|
||||||
|
if stdout:
|
||||||
|
output += f"--- STDOUT ---\n{stdout}"
|
||||||
|
if stderr:
|
||||||
|
output += f"\n--- STDERR ---\n{stderr}"
|
||||||
|
|
||||||
|
if not output:
|
||||||
|
return "Command executed successfully (no output)."
|
||||||
|
|
||||||
|
prefix: str = (
|
||||||
|
f"Command failed with exit code {process.returncode}:\n"
|
||||||
|
if process.returncode != 0
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
full: str = prefix + output
|
||||||
|
return self._truncate_output(full, max_chars, output_offset, "Output")
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error running command: {str(e)}"
|
||||||
|
|
||||||
|
def grep_search(
|
||||||
|
self,
|
||||||
|
pattern: str,
|
||||||
|
path: str = ".",
|
||||||
|
max_lines: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Search for pattern in files under path using grep (case-insensitive).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: Regular-expression / literal pattern to search for.
|
||||||
|
path: Directory or file to search (relative to repo root or absolute).
|
||||||
|
max_lines: Maximum number of matching lines to return (default 100).
|
||||||
|
offset: Line offset into the full result set for paging (default 0).
|
||||||
|
"""
|
||||||
|
resolved: str = self._resolve_path(path)
|
||||||
|
try:
|
||||||
|
command: str = f"grep -ri '{pattern}' {resolved}"
|
||||||
|
process: subprocess.Popen[str] = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
cwd=self.repo_path,
|
||||||
|
)
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
try:
|
||||||
|
stdout, stderr = process.communicate(timeout=30)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
stdout, stderr = process.communicate()
|
||||||
|
return (
|
||||||
|
f"Grep search timed out after 30 seconds.\n"
|
||||||
|
f"Stdout: {stdout}\nStderr: {stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if process.returncode != 0 and not stdout:
|
||||||
|
return f"No matches found for '{pattern}'."
|
||||||
|
|
||||||
|
all_lines: list[str] = stdout.splitlines()
|
||||||
|
total: int = len(all_lines)
|
||||||
|
page: list[str] = all_lines[offset : offset + max_lines]
|
||||||
|
result: str = "\n".join(page)
|
||||||
|
|
||||||
|
if total > offset + max_lines:
|
||||||
|
next_offset: int = offset + max_lines
|
||||||
|
result += (
|
||||||
|
f"\n\n[{total} matches total — showing lines {offset}–{offset + len(page)}. "
|
||||||
|
f"Use offset={next_offset} to see more.]"
|
||||||
|
)
|
||||||
|
|
||||||
|
if stderr:
|
||||||
|
result += f"\nError: {stderr}"
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error during grep search: {str(e)}"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from typing import Any
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
|
||||||
|
|
||||||
|
class FileTools:
|
||||||
|
"""Tools for Gitea file/content operations."""
|
||||||
|
|
||||||
|
def __init__(self, client: GiteaClient) -> None:
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def _paginate_lines(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
) -> str:
|
||||||
|
"""Return lines[offset-1 : offset-1+limit] with a paging footer if truncated.
|
||||||
|
|
||||||
|
Uses the same 1-indexed convention as CodingTools.read_file.
|
||||||
|
"""
|
||||||
|
lines: list[str] = content.splitlines()
|
||||||
|
total: int = len(lines)
|
||||||
|
start: int = offset - 1 # convert to 0-indexed
|
||||||
|
page: list[str] = lines[start : start + limit]
|
||||||
|
formatted: list[str] = [
|
||||||
|
f"{start + i + 1}: {line}" for i, line in enumerate(page)
|
||||||
|
]
|
||||||
|
result: str = "\n".join(formatted)
|
||||||
|
end_line: int = start + len(page)
|
||||||
|
if end_line < total:
|
||||||
|
next_offset: int = end_line + 1
|
||||||
|
result += (
|
||||||
|
f"\n\n[{total} lines total — showing lines {offset}–{end_line}. "
|
||||||
|
f"Re-call with offset={next_offset} to read more.]"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_file_content(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
path: str,
|
||||||
|
offset: int = 1,
|
||||||
|
limit: int = 250,
|
||||||
|
) -> str:
|
||||||
|
"""Get the content of a file from a Gitea repository with line paging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
offset: 1-indexed line to start from (default 1).
|
||||||
|
limit: Maximum number of lines to return (default 250).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = self._client.get_file_content(owner, repo, path)
|
||||||
|
raw: str = "\n".join(content) if isinstance(content, list) else content
|
||||||
|
return self._paginate_lines(raw, offset, limit)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error getting file content: {str(e)}"
|
||||||
|
|
||||||
|
def get_file_content_with_ref(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
path: str,
|
||||||
|
ref: str = "master",
|
||||||
|
offset: int = 1,
|
||||||
|
limit: int = 250,
|
||||||
|
) -> str:
|
||||||
|
"""Get file content at a specific git ref with line paging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ref: Branch, tag, or commit SHA (default 'master').
|
||||||
|
offset: 1-indexed line to start from (default 1).
|
||||||
|
limit: Maximum number of lines to return (default 250).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = self._client.get_file_content(owner, repo, path, ref)
|
||||||
|
raw: str = "\n".join(content) if isinstance(content, list) else content
|
||||||
|
return self._paginate_lines(raw, offset, limit)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error getting file content: {str(e)}"
|
||||||
|
|
||||||
|
def commit_file(self, owner: str, repo: str, path: str, message: str, content: str, branch: str) -> str:
|
||||||
|
try:
|
||||||
|
self._client.update_file(owner, repo, path, message, content, branch)
|
||||||
|
return f"File '{path}' committed successfully to {owner}/{repo}."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error committing file: {str(e)}"
|
||||||
|
|
||||||
|
def update_file(self, owner: str, repo: str, path: str, message: str, content: str, branch: str) -> str:
|
||||||
|
try:
|
||||||
|
self._client.update_file(owner, repo, path, message, content, branch)
|
||||||
|
return f"File '{path}' updated in {owner}/{repo}."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error updating file: {str(e)}"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from typing import Any
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
|
||||||
|
|
||||||
|
class GitTools:
|
||||||
|
"""Tools for Gitea git ref operations."""
|
||||||
|
|
||||||
|
def __init__(self, client: GiteaClient) -> None:
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def create_branch(self, owner: str, repo: str, ref: str, sha: str) -> str:
|
||||||
|
try:
|
||||||
|
self._client.create_ref(owner, repo, ref, sha)
|
||||||
|
return f"Branch '{ref}' created successfully in {owner}/{repo}."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error creating branch: {str(e)}"
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
from gitea.tools.issue_tools import IssueTools
|
||||||
|
from gitea.tools.pr_tools import PRTools
|
||||||
|
from gitea.tools.file_tools import FileTools
|
||||||
|
from gitea.tools.git_tools import GitTools
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaTools:
|
||||||
|
"""Facade for Gitea tool operations - delegates to focused tool classes."""
|
||||||
|
|
||||||
|
def __init__(self, client: GiteaClient) -> None:
|
||||||
|
self._client = client
|
||||||
|
self.issue_tools = IssueTools(client)
|
||||||
|
self.pr_tools = PRTools(client)
|
||||||
|
self.file_tools = FileTools(client)
|
||||||
|
self.git_tools = GitTools(client)
|
||||||
|
|
||||||
|
# ---- Issue operations (delegated) ----
|
||||||
|
|
||||||
|
def get_issue(self, owner: str, repo: str, issue_number: int) -> str:
|
||||||
|
"""Get the details of a specific issue. Args: owner (repo owner), repo (repo name), issue_number (issue ID)."""
|
||||||
|
return self.issue_tools.get_issue(owner, repo, issue_number)
|
||||||
|
|
||||||
|
def get_pull_request(self, owner: str, repo: str, pull_number: int) -> str:
|
||||||
|
"""Get the details of a specific pull request. Args: owner (repo owner), repo (repo name), pull_number (PR ID)."""
|
||||||
|
return self.pr_tools.get_pull_request(owner, repo, pull_number)
|
||||||
|
|
||||||
|
def close_issue(self, owner: str, repo: str, issue_number: int) -> str:
|
||||||
|
"""Close an issue. Args: owner (repo owner), repo (repo name), issue_number (issue ID)."""
|
||||||
|
return self.issue_tools.close_issue(owner, repo, issue_number)
|
||||||
|
|
||||||
|
def close_pull_request(self, owner: str, repo: str, pull_number: int) -> str:
|
||||||
|
"""Close a pull request. Args: owner (repo owner), repo (repo name), pull_number (PR ID)."""
|
||||||
|
return self.pr_tools.close_pull_request(owner, repo, pull_number)
|
||||||
|
|
||||||
|
def get_issue_comments(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
issue_number: int,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Get all comments on an issue. Args: owner, repo, issue_number, limit (default 20), offset (default 0)."""
|
||||||
|
return self.issue_tools.get_issue_comments(owner, repo, issue_number, limit=limit, offset=offset)
|
||||||
|
|
||||||
|
def get_pull_request_comments(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pull_number: int,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Get all comments on a pull request. Args: owner, repo, pull_number, limit (default 20), offset (default 0)."""
|
||||||
|
return self.pr_tools.get_pull_request_comments(owner, repo, pull_number, limit=limit, offset=offset)
|
||||||
|
|
||||||
|
def list_assigned_issues(self) -> list[dict]:
|
||||||
|
"""List all issues assigned to the authenticated user across all repos."""
|
||||||
|
return self.issue_tools.list_assigned_issues()
|
||||||
|
|
||||||
|
def list_assigned_pull_requests(self) -> list[dict]:
|
||||||
|
"""List all pull requests assigned to the authenticated user across all repos."""
|
||||||
|
return self.pr_tools.list_assigned_pull_requests()
|
||||||
|
|
||||||
|
def list_issues(self, owner: str, repo: str, state: str = "open") -> str:
|
||||||
|
"""List issues in a repository. Args: owner (repo owner), repo (repo name), state (open, closed, or all)."""
|
||||||
|
return self.issue_tools.list_issues(owner, repo, state)
|
||||||
|
|
||||||
|
def list_pull_requests(self, owner: str, repo: str, state: str = "open") -> str:
|
||||||
|
"""List pull requests in a repository. Args: owner (repo owner), repo (repo name), state (open, closed, or all)."""
|
||||||
|
return self.pr_tools.list_pull_requests(owner, repo, state)
|
||||||
|
|
||||||
|
def get_file_content(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
path: str,
|
||||||
|
offset: int = 1,
|
||||||
|
limit: int = 250,
|
||||||
|
) -> str:
|
||||||
|
"""Get the content of a file from a repository. Args: owner, repo, path, offset (line, default 1), limit (default 250)."""
|
||||||
|
return self.file_tools.get_file_content(owner, repo, path, offset=offset, limit=limit)
|
||||||
|
|
||||||
|
def create_pull_request(self, owner: str, repo: str, head: str, base: str, title: str, description: str = "") -> str:
|
||||||
|
"""Create a new pull request. Args: owner, repo, head (source branch), base (target branch), title, description."""
|
||||||
|
return self.pr_tools.create_pull_request(owner, repo, head, base, title, description)
|
||||||
|
|
||||||
|
def update_pull_request(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pull_number: int,
|
||||||
|
title: str | None = None,
|
||||||
|
body: str | None = None,
|
||||||
|
state: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Update an existing pull request. Args: owner, repo, pull_number, title (optional), body (optional), state (optional)."""
|
||||||
|
return self.pr_tools.update_pull_request(owner, repo, pull_number, title, body, state)
|
||||||
|
|
||||||
|
def create_issue(self, owner: str, repo: str, title: str, body: str, labels: list[str] | None = None, assignees: list[str] | None = None) -> str:
|
||||||
|
"""Create a new issue. Args: owner, repo, title, body, labels (optional), assignees (optional)."""
|
||||||
|
return self.issue_tools.create_issue(owner, repo, title, body, labels, assignees)
|
||||||
|
|
||||||
|
def create_branch(self, owner: str, repo: str, ref: str, sha: str) -> str:
|
||||||
|
"""Create a new branch in a repository. Args: owner, repo, ref (branch name), sha (commit SHA)."""
|
||||||
|
return self.git_tools.create_branch(owner, repo, ref, sha)
|
||||||
|
|
||||||
|
def commit_file(self, owner: str, repo: str, path: str, message: str, content: str, branch: str) -> str:
|
||||||
|
"""Commit a file to a repository. Args: owner, repo, path, message, content, branch."""
|
||||||
|
return self.file_tools.commit_file(owner, repo, path, message, content, branch)
|
||||||
|
|
||||||
|
def add_label_to_issue(self, owner: str, repo: str, issue_number: int, label: str) -> str:
|
||||||
|
"""Add a label to an issue. Args: owner, repo, issue_number, label."""
|
||||||
|
return self.issue_tools.add_label_to_issue(owner, repo, issue_number, label)
|
||||||
|
|
||||||
|
def add_label_to_pr(self, owner: str, repo: str, pr_number: int, label: str) -> str:
|
||||||
|
"""Add a label to a pull request. Args: owner, repo, pr_number, label."""
|
||||||
|
return self.pr_tools.add_label_to_pr(owner, repo, pr_number, label)
|
||||||
|
|
||||||
|
def add_comment_to_issue(self, owner: str, repo: str, issue_number: int, body: str) -> str:
|
||||||
|
"""Add a comment to an issue. Args: owner, repo, issue_number, body."""
|
||||||
|
return self.issue_tools.add_comment_to_issue(owner, repo, issue_number, body)
|
||||||
|
|
||||||
|
def get_pull_request_diff(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pull_number: int,
|
||||||
|
max_chars: int = 15000,
|
||||||
|
char_offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Get the diff of a pull request. Args: owner, repo, pull_number, max_chars (default 15000), char_offset (default 0)."""
|
||||||
|
return self.pr_tools.get_pull_request_diff(owner, repo, pull_number, max_chars=max_chars, char_offset=char_offset)
|
||||||
|
|
||||||
|
def get_pull_request_patch(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pull_number: int,
|
||||||
|
max_chars: int = 15000,
|
||||||
|
char_offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Get the patch of a pull request. Args: owner, repo, pull_number, max_chars (default 15000), char_offset (default 0)."""
|
||||||
|
return self.pr_tools.get_pull_request_patch(owner, repo, pull_number, max_chars=max_chars, char_offset=char_offset)
|
||||||
|
|
||||||
|
def approve_pull_request(self, owner: str, repo: str, pull_number: int, comment: str) -> str:
|
||||||
|
"""Approve a pull request. Args: owner, repo, pull_number, comment."""
|
||||||
|
return self.pr_tools.approve_pull_request(owner, repo, pull_number, comment)
|
||||||
|
|
||||||
|
def request_changes(self, owner: str, repo: str, pull_number: int, comment: str) -> str:
|
||||||
|
"""Request changes on a pull request. Args: owner, repo, pull_number, comment."""
|
||||||
|
return self.pr_tools.request_changes(owner, repo, pull_number, comment)
|
||||||
|
|
||||||
|
def add_comment(self, owner: str, repo: str, issue_number: int, body: str) -> str:
|
||||||
|
"""Add a comment to an issue. Args: owner, repo, issue_number, body."""
|
||||||
|
return self.issue_tools.add_comment(owner, repo, issue_number, body)
|
||||||
|
|
||||||
|
def add_label(self, owner: str, repo: str, issue_number: int, label: str) -> str:
|
||||||
|
"""Add a label to an issue. Args: owner, repo, issue_number, label."""
|
||||||
|
return self.issue_tools.add_label(owner, repo, issue_number, label)
|
||||||
|
|
||||||
|
def get_file_content_with_ref(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
path: str,
|
||||||
|
ref: str = "master",
|
||||||
|
offset: int = 1,
|
||||||
|
limit: int = 250,
|
||||||
|
) -> str:
|
||||||
|
"""Get file content with a specific git ref. Args: owner, repo, path, ref (branch/tag), offset (line, default 1), limit (default 250)."""
|
||||||
|
return self.file_tools.get_file_content_with_ref(owner, repo, path, ref=ref, offset=offset, limit=limit)
|
||||||
|
|
||||||
|
def update_file(self, owner: str, repo: str, path: str, message: str, content: str, branch: str) -> str:
|
||||||
|
"""Update a file in a repository. Args: owner, repo, path, message, content, branch."""
|
||||||
|
return self.file_tools.update_file(owner, repo, path, message, content, branch)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Tools for Gitea issue operations."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
from gitea.models import IssueModel, CommentModel, LabelModel
|
||||||
|
|
||||||
|
|
||||||
|
class IssueTools:
|
||||||
|
"""Tools for Gitea issue operations."""
|
||||||
|
|
||||||
|
def __init__(self, client: GiteaClient) -> None:
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def get_issue(self, owner: str, repo: str, issue_number: int) -> str:
|
||||||
|
try:
|
||||||
|
issue: IssueModel = self._client.get_issue(owner, repo, issue_number)
|
||||||
|
return issue.model_dump_json(indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error getting issue: {str(e)}"
|
||||||
|
|
||||||
|
def close_issue(self, owner: str, repo: str, issue_number: int) -> str:
|
||||||
|
try:
|
||||||
|
self._client.close_issue(owner, repo, issue_number)
|
||||||
|
return f"Issue #{issue_number} closed successfully."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error closing issue: {str(e)}"
|
||||||
|
|
||||||
|
def get_issue_comments(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
issue_number: int,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Get comments on an issue with optional paging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of comments to return (default 20).
|
||||||
|
offset: Zero-based comment index to start from (default 0).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
comments: list[CommentModel] = self._client.get_issue_comments(
|
||||||
|
owner, repo, issue_number
|
||||||
|
)
|
||||||
|
total: int = len(comments)
|
||||||
|
page: list[CommentModel] = comments[offset : offset + limit]
|
||||||
|
result: str = json.dumps([c.model_dump() for c in page], indent=2)
|
||||||
|
if total > offset + limit:
|
||||||
|
next_offset: int = offset + limit
|
||||||
|
result += (
|
||||||
|
f"\n\n[{total} comments total — showing {offset}–{offset + len(page)}. "
|
||||||
|
f"Re-call with offset={next_offset} to see more.]"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error getting issue comments: {str(e)}"
|
||||||
|
|
||||||
|
def list_assigned_issues(self) -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
repos = self._client.list_all_user_repos()
|
||||||
|
all_issues: list[dict[str, Any]] = []
|
||||||
|
for repo in repos:
|
||||||
|
owner = repo.owner
|
||||||
|
repo_name = repo.name
|
||||||
|
issues = self._client.list_assigned_issues(owner, repo_name)
|
||||||
|
if issues:
|
||||||
|
all_issues.extend([issue.model_dump() if hasattr(issue, 'model_dump') else issue for issue in issues])
|
||||||
|
return all_issues
|
||||||
|
except Exception as e:
|
||||||
|
print(f"DEBUG: list_assigned_issues error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def list_issues(self, owner: str, repo: str, state: str = "open") -> str:
|
||||||
|
try:
|
||||||
|
issues = self._client.list_repo_issues(owner, repo, state)
|
||||||
|
if not issues:
|
||||||
|
return f"No issues in {owner}/{repo}."
|
||||||
|
summary = [f"#{issue.number}: {issue.title}" for issue in issues]
|
||||||
|
return "\n".join(summary)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error listing issues: {str(e)}"
|
||||||
|
|
||||||
|
def create_issue(self, owner: str, repo: str, title: str, body: str, labels: list[str] | None = None, assignees: list[str] | None = None) -> str:
|
||||||
|
try:
|
||||||
|
issue = self._client.create_issue(owner, repo, title, body, labels, assignees)
|
||||||
|
return f"Issue #{issue.number} created successfully in {owner}/{repo}."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error creating issue: {str(e)}"
|
||||||
|
|
||||||
|
def add_label_to_issue(self, owner: str, repo: str, issue_number: int, label: str) -> str:
|
||||||
|
try:
|
||||||
|
self._client.add_label(owner, repo, issue_number, label)
|
||||||
|
return f"Label '{label}' added to issue #{issue_number}."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error adding label to issue #{issue_number}: {e}"
|
||||||
|
|
||||||
|
def add_comment_to_issue(self, owner: str, repo: str, issue_number: int, body: str) -> str:
|
||||||
|
try:
|
||||||
|
self._client.add_comment(owner, repo, issue_number, body)
|
||||||
|
return f"Comment added to issue #{issue_number}."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error adding comment to issue #{issue_number}: {e}"
|
||||||
|
|
||||||
|
def add_comment(self, owner: str, repo: str, issue_number: int, body: str) -> str:
|
||||||
|
try:
|
||||||
|
comment = self._client.add_comment(owner, repo, issue_number, body)
|
||||||
|
return f"Comment added to #{issue_number}."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error adding comment: {str(e)}"
|
||||||
|
|
||||||
|
def add_label(self, owner: str, repo: str, issue_number: int, label: str) -> str:
|
||||||
|
try:
|
||||||
|
self._client.add_label(owner, repo, issue_number, label)
|
||||||
|
return f"Label '{label}' added to #{issue_number}."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error adding label: {str(e)}"
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Tools for Gitea pull request operations."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
from gitea.models import PullRequestModel, CommentModel
|
||||||
|
|
||||||
|
_MAX_DIFF_CHARS: int = 15_000
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_diff(
|
||||||
|
text: str,
|
||||||
|
max_chars: int = _MAX_DIFF_CHARS,
|
||||||
|
char_offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Slice [char_offset : char_offset+max_chars] from text, cutting at a hunk boundary."""
|
||||||
|
total: int = len(text)
|
||||||
|
chunk: str = text[char_offset : char_offset + max_chars]
|
||||||
|
if char_offset > 0 or (char_offset + max_chars) < total:
|
||||||
|
# Try to cut at a diff hunk boundary (@@) for coherence
|
||||||
|
hunk_boundary: int = chunk.rfind("\n@@")
|
||||||
|
if hunk_boundary > int(len(chunk) * 0.6):
|
||||||
|
chunk = chunk[:hunk_boundary]
|
||||||
|
next_offset: int = char_offset + len(chunk)
|
||||||
|
chunk += (
|
||||||
|
f"\n\n[Diff truncated — {total} chars total. "
|
||||||
|
f"Showing chars {char_offset}–{next_offset}. "
|
||||||
|
f"Re-call with char_offset={next_offset} to read more.]"
|
||||||
|
)
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
|
||||||
|
class PRTools:
|
||||||
|
"""Tools for Gitea pull request operations."""
|
||||||
|
|
||||||
|
def __init__(self, client: GiteaClient) -> None:
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def get_pull_request(self, owner: str, repo: str, pull_number: int) -> str:
|
||||||
|
try:
|
||||||
|
pr: PullRequestModel = self._client.get_pull_request(owner, repo, pull_number)
|
||||||
|
return pr.model_dump_json(indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error getting pull request: {str(e)}"
|
||||||
|
|
||||||
|
def close_pull_request(self, owner: str, repo: str, pull_number: int) -> str:
|
||||||
|
try:
|
||||||
|
self._client.close_pull_request(owner, repo, pull_number)
|
||||||
|
return f"Pull request #{pull_number} closed successfully."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error closing pull request: {str(e)}"
|
||||||
|
|
||||||
|
def get_pull_request_comments(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pull_number: int,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Get comments on a pull request with optional paging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of comments to return (default 20).
|
||||||
|
offset: Zero-based comment index to start from (default 0).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
comments: list[CommentModel] = self._client.get_pull_request_comments(
|
||||||
|
owner, repo, pull_number
|
||||||
|
)
|
||||||
|
total: int = len(comments)
|
||||||
|
page: list[CommentModel] = comments[offset : offset + limit]
|
||||||
|
result: str = json.dumps([c.model_dump() for c in page], indent=2)
|
||||||
|
if total > offset + limit:
|
||||||
|
next_offset: int = offset + limit
|
||||||
|
result += (
|
||||||
|
f"\n\n[{total} comments total — showing {offset}–{offset + len(page)}. "
|
||||||
|
f"Re-call with offset={next_offset} to see more.]"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error getting PR comments: {str(e)}"
|
||||||
|
|
||||||
|
def list_assigned_pull_requests(self) -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
repos = self._client.list_all_user_repos()
|
||||||
|
all_prs: list[dict[str, Any]] = []
|
||||||
|
for repo_info in repos:
|
||||||
|
repo_owner = repo_info.owner
|
||||||
|
repo_name = repo_info.name
|
||||||
|
prs = self._client.list_assigned_pull_requests(repo_owner, repo_name)
|
||||||
|
if prs:
|
||||||
|
all_prs.extend([pr.model_dump() if hasattr(pr, 'model_dump') else pr for pr in prs])
|
||||||
|
return all_prs
|
||||||
|
except Exception as e:
|
||||||
|
print(f"DEBUG: list_assigned_pull_requests error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def list_pull_requests(self, owner: str, repo: str, state: str = "open") -> str:
|
||||||
|
try:
|
||||||
|
prs = self._client.list_repo_pull_requests(owner, repo, state)
|
||||||
|
if not prs:
|
||||||
|
return f"No PRs in {owner}/{repo}."
|
||||||
|
summary = [f"#{pr.number}: {pr.title}" for pr in prs]
|
||||||
|
return "\n".join(summary)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error listing PRs: {str(e)}"
|
||||||
|
|
||||||
|
def create_pull_request(self, owner: str, repo: str, head: str, base: str, title: str, description: str = "") -> str:
|
||||||
|
try:
|
||||||
|
pr = self._client.create_pr_via_tea(owner, repo, title, description, head, base)
|
||||||
|
return pr.model_dump_json(indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error creating PR: {str(e)}"
|
||||||
|
|
||||||
|
def update_pull_request(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pull_number: int,
|
||||||
|
title: str | None = None,
|
||||||
|
body: str | None = None,
|
||||||
|
state: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
pr = self._client.update_pull_request(owner, repo, pull_number, title, body, state)
|
||||||
|
return pr.model_dump_json(indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error updating PR #{pull_number}: {str(e)}"
|
||||||
|
|
||||||
|
def add_label_to_pr(self, owner: str, repo: str, pr_number: int, label: str) -> str:
|
||||||
|
try:
|
||||||
|
self._client.add_label_pr(owner, repo, pr_number, label)
|
||||||
|
return f"Label '{label}' added to PR #{pr_number}."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error adding label to PR #{pr_number}: {e}"
|
||||||
|
|
||||||
|
def get_pull_request_diff(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pull_number: int,
|
||||||
|
max_chars: int = _MAX_DIFF_CHARS,
|
||||||
|
char_offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Get the diff of a pull request, with truncation and offset paging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_chars: Maximum characters to return (default 15 000).
|
||||||
|
char_offset: Character offset to start reading from (default 0).
|
||||||
|
Increment by max_chars to page through a large diff.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
diff: str = self._client.get_pull_request_diff(owner, repo, pull_number)
|
||||||
|
return _truncate_diff(diff, max_chars, char_offset)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error getting PR diff: {str(e)}"
|
||||||
|
|
||||||
|
def get_pull_request_patch(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pull_number: int,
|
||||||
|
max_chars: int = _MAX_DIFF_CHARS,
|
||||||
|
char_offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Get the patch of a pull request, with truncation and offset paging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_chars: Maximum characters to return (default 15 000).
|
||||||
|
char_offset: Character offset to start reading from (default 0).
|
||||||
|
Increment by max_chars to page through a large patch.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
patch: str = self._client.get_pull_request_patch(owner, repo, pull_number)
|
||||||
|
return _truncate_diff(patch, max_chars, char_offset)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error getting PR patch: {str(e)}"
|
||||||
|
|
||||||
|
def approve_pull_request(self, owner: str, repo: str, pull_number: int, comment: str) -> str:
|
||||||
|
try:
|
||||||
|
self._client.approve_pr(owner, repo, pull_number, comment)
|
||||||
|
return f"Approved PR #{pull_number}."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error approving PR: {str(e)}"
|
||||||
|
|
||||||
|
def request_changes(self, owner: str, repo: str, pull_number: int, comment: str) -> str:
|
||||||
|
try:
|
||||||
|
self._client.request_changes_pr(owner, repo, pull_number, comment)
|
||||||
|
return f"Requested changes on PR #{pull_number}."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error requesting changes: {str(e)}"
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
"""Research tools for the coding agent: web search and URL fetching.
|
||||||
|
|
||||||
|
Search backend priority (web_search):
|
||||||
|
1. SearXNG — self-hosted at SEARXNG_URL (default: https://searxng.meeks.freeddns.org)
|
||||||
|
2. DDGS — duckduckgo-search library, with exponential-backoff retry
|
||||||
|
3. httpx — raw DuckDuckGo HTML scrape (zero-dep last resort)
|
||||||
|
|
||||||
|
Extraction pipeline (fetch_url):
|
||||||
|
1. trafilatura — state-of-the-art boilerplate removal, Markdown output
|
||||||
|
2. readability-lxml + markdownify — Mozilla Readability port, fallback
|
||||||
|
3. markdownify on full HTML — last resort if readability fails
|
||||||
|
4. regex strip — zero-dep absolute last resort
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from duckduckgo_search import DDGS # type: ignore[import-untyped]
|
||||||
|
from duckduckgo_search.exceptions import ( # type: ignore[import-untyped]
|
||||||
|
DuckDuckGoSearchException,
|
||||||
|
RatelimitException,
|
||||||
|
)
|
||||||
|
|
||||||
|
from gitea.config import SEARXNG_URL, SEARXNG_USERNAME, SEARXNG_PASSWORD
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("research-tools")
|
||||||
|
|
||||||
|
_DEFAULT_TIMEOUT: int = 20
|
||||||
|
_MAX_CONTENT_CHARS: int = 20_000
|
||||||
|
_USER_AGENT: str = (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
# SearXNG instance
|
||||||
|
_SEARXNG_URL: str = SEARXNG_URL
|
||||||
|
_SEARXNG_USERNAME: str = SEARXNG_USERNAME
|
||||||
|
_SEARXNG_PASSWORD: str = SEARXNG_PASSWORD
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchTools:
|
||||||
|
"""Web search and URL fetching tools for the coding agent."""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _smart_truncate(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
max_chars: int = _MAX_CONTENT_CHARS,
|
||||||
|
char_offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Slice [char_offset : char_offset+max_chars] and truncate at a paragraph boundary.
|
||||||
|
|
||||||
|
Prefers cutting at a blank-line paragraph boundary rather than mid-sentence
|
||||||
|
so the LLM receives a coherent chunk.
|
||||||
|
"""
|
||||||
|
total: int = len(text)
|
||||||
|
chunk: str = text[char_offset : char_offset + max_chars]
|
||||||
|
if char_offset == 0 and len(chunk) <= max_chars and total <= max_chars:
|
||||||
|
return text # Common fast-path: content fits entirely
|
||||||
|
if len(chunk) >= max_chars:
|
||||||
|
last_para: int = chunk.rfind("\n\n")
|
||||||
|
if last_para > int(max_chars * 0.7):
|
||||||
|
chunk = chunk[:last_para]
|
||||||
|
next_offset: int = char_offset + len(chunk)
|
||||||
|
if next_offset < total:
|
||||||
|
tail = (
|
||||||
|
f"\n\n[Content truncated — {total} chars total. "
|
||||||
|
f"Showing chars {char_offset}–{next_offset}. "
|
||||||
|
f"Re-call fetch_url with char_offset={next_offset} to read more.]"
|
||||||
|
)
|
||||||
|
return chunk + tail
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
def _html_to_markdown(self, html: str) -> str:
|
||||||
|
"""Convert HTML to clean Markdown.
|
||||||
|
|
||||||
|
Fallback chain:
|
||||||
|
1. trafilatura (best content extractor — removes nav/ads/sidebars)
|
||||||
|
2. readability-lxml + markdownify (Mozilla Readability port)
|
||||||
|
3. markdownify on full HTML
|
||||||
|
4. regex strip (zero-dep last resort)
|
||||||
|
"""
|
||||||
|
# --- 1. trafilatura ---
|
||||||
|
try:
|
||||||
|
import trafilatura # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
extracted: str | None = trafilatura.extract(
|
||||||
|
html,
|
||||||
|
output_format="markdown",
|
||||||
|
include_tables=True,
|
||||||
|
include_comments=False,
|
||||||
|
favor_precision=True,
|
||||||
|
deduplicate=True,
|
||||||
|
)
|
||||||
|
if extracted and len(extracted) > 200:
|
||||||
|
return re.sub(r"\n{3,}", "\n\n", extracted).strip()
|
||||||
|
except ImportError:
|
||||||
|
logger.debug("trafilatura not installed; falling back to readability")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"trafilatura extraction failed: {exc}")
|
||||||
|
|
||||||
|
# --- 2. readability-lxml + markdownify ---
|
||||||
|
try:
|
||||||
|
from readability import Document # type: ignore[import-untyped]
|
||||||
|
from markdownify import markdownify as md # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
doc = Document(html)
|
||||||
|
clean_html: str = doc.summary()
|
||||||
|
text: str = md(clean_html, strip=["script", "style"])
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||||
|
if text.strip() and len(text.strip()) > 100:
|
||||||
|
return text.strip()
|
||||||
|
except ImportError:
|
||||||
|
logger.debug("readability-lxml or markdownify not installed")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"readability+markdownify extraction failed: {exc}")
|
||||||
|
|
||||||
|
# --- 3. markdownify on full HTML ---
|
||||||
|
try:
|
||||||
|
from markdownify import markdownify as md # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
text = md(html, strip=["script", "style", "nav", "footer", "aside"])
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||||
|
if text.strip():
|
||||||
|
return text.strip()
|
||||||
|
except ImportError:
|
||||||
|
logger.debug("markdownify not installed; using regex fallback")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"markdownify failed: {exc}")
|
||||||
|
|
||||||
|
# --- 4. Regex strip (zero-dep fallback) ---
|
||||||
|
html = re.sub(
|
||||||
|
r"<(script|style)[^>]*?>.*?</\1>", "", html,
|
||||||
|
flags=re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
text = re.sub(r"<[^>]+>", " ", html)
|
||||||
|
text = re.sub(r"[ \t]+", " ", text)
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||||
|
entities: dict[str, str] = {
|
||||||
|
"&": "&", "<": "<", ">": ">",
|
||||||
|
""": '"', "'": "'", " ": " ",
|
||||||
|
"—": "—", "–": "–", "…": "…",
|
||||||
|
}
|
||||||
|
for entity, char in entities.items():
|
||||||
|
text = text.replace(entity, char)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
def _format_results(
|
||||||
|
self, results: list[dict[str, str]], query: str
|
||||||
|
) -> str:
|
||||||
|
"""Format search results as a numbered list for LLM consumption."""
|
||||||
|
lines: list[str] = [f"Search results for: '{query}'\n"]
|
||||||
|
for i, r in enumerate(results, 1):
|
||||||
|
title: str = r.get("title", "No title")
|
||||||
|
url: str = r.get("href") or r.get("url", "")
|
||||||
|
snippet: str = (
|
||||||
|
r.get("body") or r.get("snippet") or r.get("content", "")
|
||||||
|
)[:250]
|
||||||
|
date: str = r.get("published_date", "")
|
||||||
|
date_str: str = f"\n Date: {date}" if date else ""
|
||||||
|
lines.append(
|
||||||
|
f"[{i}] {title}\n"
|
||||||
|
f" URL: {url}\n"
|
||||||
|
f" {snippet}{date_str}\n"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _search_searxng(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
num_results: int,
|
||||||
|
time_range: str = "",
|
||||||
|
categories: str = "general",
|
||||||
|
language: str = "en",
|
||||||
|
) -> str | None:
|
||||||
|
"""Search via self-hosted SearXNG JSON API.
|
||||||
|
|
||||||
|
Returns formatted results string on success, or None if the instance
|
||||||
|
is unreachable so the caller can fall through to the next backend.
|
||||||
|
"""
|
||||||
|
if not _SEARXNG_URL:
|
||||||
|
logger.info("SearXNG URL is not configured; skipping SearXNG search.")
|
||||||
|
return None
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"q": query,
|
||||||
|
"format": "json",
|
||||||
|
"language": language,
|
||||||
|
"categories": categories,
|
||||||
|
}
|
||||||
|
if time_range:
|
||||||
|
params["time_range"] = time_range
|
||||||
|
|
||||||
|
auth = None
|
||||||
|
if _SEARXNG_USERNAME and _SEARXNG_PASSWORD:
|
||||||
|
auth = (_SEARXNG_USERNAME, _SEARXNG_PASSWORD)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(
|
||||||
|
timeout=_DEFAULT_TIMEOUT, follow_redirects=True, auth=auth
|
||||||
|
) as client:
|
||||||
|
response = client.get(
|
||||||
|
f"{_SEARXNG_URL}/search",
|
||||||
|
params=params,
|
||||||
|
headers={"User-Agent": _USER_AGENT},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data: dict[str, Any] = response.json()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"SearXNG unavailable ({_SEARXNG_URL}): {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw_results: list[dict[str, Any]] = data.get("results", [])
|
||||||
|
if not raw_results:
|
||||||
|
return None # Let caller fall through to next backend
|
||||||
|
|
||||||
|
# Normalise SearXNG fields to our standard format
|
||||||
|
normalised: list[dict[str, str]] = [
|
||||||
|
{
|
||||||
|
"title": r.get("title", "No title"),
|
||||||
|
"href": r.get("url", ""),
|
||||||
|
"body": r.get("content", "")[:250],
|
||||||
|
"published_date": r.get("publishedDate", ""),
|
||||||
|
}
|
||||||
|
for r in raw_results[:num_results]
|
||||||
|
]
|
||||||
|
return self._format_results(normalised, query)
|
||||||
|
|
||||||
|
def _web_search_fallback(self, query: str, num_results: int) -> str:
|
||||||
|
"""DuckDuckGo HTML scraping fallback when duckduckgo-search is not installed."""
|
||||||
|
try:
|
||||||
|
headers: dict[str, str] = {
|
||||||
|
"User-Agent": _USER_AGENT,
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=_DEFAULT_TIMEOUT, follow_redirects=True) as client:
|
||||||
|
response = client.get(
|
||||||
|
"https://html.duckduckgo.com/html/",
|
||||||
|
params={"q": query, "kl": "us-en"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
blocks = re.findall(
|
||||||
|
r'<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)</a>'
|
||||||
|
r'.*?<a[^>]+class="result__snippet"[^>]*>(.*?)</a>',
|
||||||
|
response.text,
|
||||||
|
flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
if not blocks:
|
||||||
|
return (
|
||||||
|
f"No results found for '{query}'. "
|
||||||
|
"Try rephrasing or use fetch_url with a known documentation URL."
|
||||||
|
)
|
||||||
|
lines: list[str] = [f"Search results for: '{query}'\n"]
|
||||||
|
for i, (url, title, snippet) in enumerate(blocks[:num_results], 1):
|
||||||
|
clean_title = re.sub(r"<[^>]+>", "", title).strip()
|
||||||
|
clean_snippet = re.sub(r"<[^>]+>", "", snippet).strip()
|
||||||
|
lines.append(f"[{i}] {clean_title}\n URL: {url}\n {clean_snippet}\n")
|
||||||
|
return "\n".join(lines)
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Search error: {exc}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Public tools
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def web_search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
num_results: int = 8,
|
||||||
|
time_range: str = "",
|
||||||
|
categories: str = "general",
|
||||||
|
language: str = "en",
|
||||||
|
) -> str:
|
||||||
|
"""Search the web and return structured, numbered results.
|
||||||
|
|
||||||
|
Uses a self-hosted SearXNG instance as primary backend (private,
|
||||||
|
no rate limits, aggregates Google/Bing/Wikipedia/etc.), falling back
|
||||||
|
to DuckDuckGo (DDGS) if SearXNG is unreachable.
|
||||||
|
|
||||||
|
Use this tool when you need to:
|
||||||
|
- Find documentation for a library, framework, or API
|
||||||
|
- Look up error messages, stack traces, or known bugs
|
||||||
|
- Discover best practices, community conventions, or coding patterns
|
||||||
|
- Find package release notes, changelogs, or migration guides
|
||||||
|
- Research a technology, tool, or concept you are unfamiliar with
|
||||||
|
|
||||||
|
Do NOT use this tool if you already have the exact URL — use fetch_url instead.
|
||||||
|
Result URLs can be passed directly to fetch_url for full page content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Specific natural-language or technical search query.
|
||||||
|
Good: "Python httpx async retry on timeout 2024"
|
||||||
|
Bad: "httpx"
|
||||||
|
num_results: Results to return (1–20, default 8). 5–10 is optimal.
|
||||||
|
time_range: Optional recency filter — "day", "month", or "year".
|
||||||
|
categories: SearXNG category — "general" (default), "it", "news",
|
||||||
|
"science", "files", "videos", "music".
|
||||||
|
language: ISO language code, e.g. "en" (default), "sv", "de".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Numbered list [1], [2], ... each with title, URL, snippet, and date.
|
||||||
|
On failure, returns an actionable error string — use fetch_url as fallback.
|
||||||
|
"""
|
||||||
|
num_results = min(max(1, num_results), 20)
|
||||||
|
|
||||||
|
# --- Tier 1: SearXNG (self-hosted, preferred) ---
|
||||||
|
searxng_result = self._search_searxng(
|
||||||
|
query, num_results, time_range=time_range,
|
||||||
|
categories=categories, language=language,
|
||||||
|
)
|
||||||
|
if searxng_result is not None:
|
||||||
|
return searxng_result
|
||||||
|
logger.info("SearXNG unavailable; falling back to DuckDuckGo")
|
||||||
|
|
||||||
|
# --- Tier 2: DDGS library (handles sessions, cookies, rate limits) ---
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
with DDGS(timeout=_DEFAULT_TIMEOUT) as ddgs:
|
||||||
|
results: list[dict[str, str]] = list(
|
||||||
|
ddgs.text(query, max_results=num_results, region="us-en")
|
||||||
|
)
|
||||||
|
if not results:
|
||||||
|
return (
|
||||||
|
f"No results found for '{query}'. "
|
||||||
|
"Try rephrasing, or use fetch_url with a known documentation URL."
|
||||||
|
)
|
||||||
|
return self._format_results(results, query)
|
||||||
|
|
||||||
|
except RatelimitException as exc:
|
||||||
|
last_exc = exc
|
||||||
|
wait: int = 2 ** attempt # 1 s → 2 s → 4 s
|
||||||
|
logger.warning(
|
||||||
|
f"DuckDuckGo rate limit (attempt {attempt + 1}/3), "
|
||||||
|
f"retrying in {wait}s…"
|
||||||
|
)
|
||||||
|
time.sleep(wait)
|
||||||
|
|
||||||
|
except DuckDuckGoSearchException as exc:
|
||||||
|
return (
|
||||||
|
f"Search unavailable: {exc}. "
|
||||||
|
"Try fetch_url with a direct documentation URL instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"web_search error: {exc}")
|
||||||
|
return f"Search error: {exc}"
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"DuckDuckGo rate limit exceeded after 3 retries ({last_exc}). "
|
||||||
|
"Wait a moment and retry, or use fetch_url with a known URL."
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch_url(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
extract_text: bool = True,
|
||||||
|
max_chars: int = _MAX_CONTENT_CHARS,
|
||||||
|
char_offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Fetch the content of a URL and return it as clean, readable Markdown.
|
||||||
|
|
||||||
|
Use this tool when you need to:
|
||||||
|
- Read the full content of a documentation page, API reference, or README
|
||||||
|
- Follow up on a URL returned by web_search to get the complete text
|
||||||
|
- Read a GitHub issue, Stack Overflow answer, or blog post in full
|
||||||
|
- Access a package's changelog, migration guide, or specification
|
||||||
|
- Read a JSON API response, config schema, or data format at a known URL
|
||||||
|
|
||||||
|
Do NOT use this tool for binary files (images, PDFs, executables).
|
||||||
|
Note: pages that require JavaScript to render may return incomplete content.
|
||||||
|
For JS-heavy pages, prefer web_search first to find a cached/static mirror.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The full URL to fetch (must start with http:// or https://).
|
||||||
|
extract_text: If True (default), extract and clean the main content
|
||||||
|
as Markdown, stripping navigation, ads, and boilerplate.
|
||||||
|
Set to False to get raw HTML/JSON (useful for schemas).
|
||||||
|
max_chars: Maximum characters to return per call (default 20,000).
|
||||||
|
The tool cuts at a paragraph boundary when truncating.
|
||||||
|
char_offset: Character offset into the extracted content to start
|
||||||
|
reading from (default 0). Increment by max_chars to
|
||||||
|
page through content larger than max_chars.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Clean Markdown text of the main content (HTML pages), pretty-printed
|
||||||
|
JSON (JSON responses), or plain text (text/plain, .md, .txt).
|
||||||
|
Returns an error string on HTTP errors, timeouts, or invalid URLs.
|
||||||
|
"""
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
return f"Invalid URL '{url}': must start with http:// or https://"
|
||||||
|
|
||||||
|
try:
|
||||||
|
headers: dict[str, str] = {
|
||||||
|
"User-Agent": _USER_AGENT,
|
||||||
|
"Accept": (
|
||||||
|
"text/html,application/xhtml+xml,application/xml;"
|
||||||
|
"q=0.9,application/json,*/*;q=0.8"
|
||||||
|
),
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
}
|
||||||
|
with httpx.Client(
|
||||||
|
timeout=_DEFAULT_TIMEOUT, follow_redirects=True
|
||||||
|
) as client:
|
||||||
|
response = client.get(url, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
content_type: str = response.headers.get("content-type", "")
|
||||||
|
raw: str = response.text
|
||||||
|
text: str
|
||||||
|
|
||||||
|
# JSON → pretty print
|
||||||
|
if "application/json" in content_type:
|
||||||
|
try:
|
||||||
|
data: Any = response.json()
|
||||||
|
text = json.dumps(data, indent=2)
|
||||||
|
except Exception:
|
||||||
|
text = raw
|
||||||
|
|
||||||
|
# Plain text / Markdown / reStructuredText → return as-is
|
||||||
|
elif "text/plain" in content_type or url.endswith(
|
||||||
|
(".md", ".txt", ".rst")
|
||||||
|
):
|
||||||
|
text = raw
|
||||||
|
|
||||||
|
# HTML → extract main content as Markdown
|
||||||
|
elif extract_text and (
|
||||||
|
"text/html" in content_type
|
||||||
|
or raw.lstrip().startswith(("<html", "<!DOCTYPE", "<!doctype"))
|
||||||
|
):
|
||||||
|
text = self._html_to_markdown(raw)
|
||||||
|
|
||||||
|
else:
|
||||||
|
text = raw
|
||||||
|
|
||||||
|
return self._smart_truncate(text, max_chars, char_offset)
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
return (
|
||||||
|
f"Failed to fetch '{url}' "
|
||||||
|
f"(HTTP {exc.response.status_code}): {exc}"
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return (
|
||||||
|
f"Request to '{url}' timed out after {_DEFAULT_TIMEOUT}s. "
|
||||||
|
"Try a different URL or break the page into smaller fetches."
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"fetch_url error for '{url}': {exc}")
|
||||||
|
return f"Error fetching '{url}': {exc}"
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from .config import GITEA_REPOS_ROOT, GITEA_URL, GITEA_TOKEN
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("gitea-workspace")
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceManager:
|
||||||
|
"""Manages local workspace for Gitea repositories."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.root_dir: Path = Path(GITEA_REPOS_ROOT).expanduser().resolve()
|
||||||
|
self.root_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._configure_git_credentials()
|
||||||
|
|
||||||
|
def _configure_git_credentials(self) -> None:
|
||||||
|
try:
|
||||||
|
# Unset any global configs we might have set previously
|
||||||
|
subprocess.run(
|
||||||
|
["git", "config", "--global", "--unset", "credential.helper"],
|
||||||
|
capture_output=True
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "config", "--global", "--unset", "user.name"],
|
||||||
|
capture_output=True
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "config", "--global", "--unset", "user.email"],
|
||||||
|
capture_output=True
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error unsetting global configs: {e}")
|
||||||
|
|
||||||
|
def _configure_repo_user(self, repo_path: Path) -> None:
|
||||||
|
try:
|
||||||
|
# Configure credential helper locally for the repo
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(repo_path), "config", "credential.helper", "store"],
|
||||||
|
check=True, capture_output=True
|
||||||
|
)
|
||||||
|
# Write to ~/.git-credentials
|
||||||
|
parsed = urlparse(GITEA_URL.rstrip("/"))
|
||||||
|
cred_line = f"{parsed.scheme}://meeks-ai:{GITEA_TOKEN}@{parsed.netloc}\n"
|
||||||
|
cred_file = Path("~/.git-credentials").expanduser()
|
||||||
|
if cred_file.exists():
|
||||||
|
content = cred_file.read_text()
|
||||||
|
if cred_line.strip() not in content:
|
||||||
|
cred_file.write_text(content + cred_line)
|
||||||
|
else:
|
||||||
|
cred_file.write_text(cred_line)
|
||||||
|
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
client = GiteaClient()
|
||||||
|
user = client.get_authenticated_user()
|
||||||
|
if user:
|
||||||
|
name = user.full_name or user.login or "meeks-ai"
|
||||||
|
email = user.email or "micke_ingvarsson+ai@hotmail.com"
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(repo_path), "config", "user.name", name],
|
||||||
|
check=True, capture_output=True
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(repo_path), "config", "user.email", email],
|
||||||
|
check=True, capture_output=True
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error configuring local git user: {e}")
|
||||||
|
|
||||||
|
def get_repo_path(self, repo_full_name: str) -> Path:
|
||||||
|
parts: list[str] = repo_full_name.split("/")
|
||||||
|
return self.root_dir / parts[0] / parts[1]
|
||||||
|
|
||||||
|
def _get_authenticated_url(self, repo_full_name: str) -> str:
|
||||||
|
parsed = urlparse(GITEA_URL.rstrip("/"))
|
||||||
|
path = parsed.path.rstrip("/")
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}{path}/{repo_full_name}.git"
|
||||||
|
|
||||||
|
def sanitize_repo(self, repo_full_name: str, repo_path: Path) -> None:
|
||||||
|
try:
|
||||||
|
auth_url = self._get_authenticated_url(repo_full_name)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(repo_path), "remote", "set-url", "origin", auth_url],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
self._configure_repo_user(repo_path)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(repo_path), "reset", "--hard", "HEAD"],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(repo_path), "clean", "-fdx"],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(repo_path), "checkout", "main"],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(repo_path), "checkout", "master"],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(repo_path), "pull", "origin", "main"],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(repo_path), "pull", "origin", "master"],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during sanitization: {e}")
|
||||||
|
|
||||||
|
def clone_repo(self, repo_full_name: str, clone_url: str | None = None) -> Path:
|
||||||
|
repo_path: Path = self.get_repo_path(repo_full_name)
|
||||||
|
if repo_path.exists():
|
||||||
|
if not (repo_path / ".git").exists():
|
||||||
|
new_path: Path = repo_path.parent / f"{repo_full_name.replace('/', '_')}_old"
|
||||||
|
if new_path.exists():
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(new_path)
|
||||||
|
repo_path.rename(new_path)
|
||||||
|
return repo_path
|
||||||
|
|
||||||
|
logger.info(f"Cloning repository {repo_full_name} to {repo_path}...")
|
||||||
|
auth_url = self._get_authenticated_url(repo_full_name)
|
||||||
|
subprocess.run(["git", "clone", auth_url, str(repo_path)], check=True, capture_output=True)
|
||||||
|
self._configure_repo_user(repo_path)
|
||||||
|
return repo_path
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
from gitea.tools.gitea_tools import GiteaTools
|
||||||
|
from gitea.config import AGENT_MODEL_ID, AGENT_MAX_RETRIES
|
||||||
|
from core.orchestrator import AgentOrchestrator
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Setup logging to logs folder
|
||||||
|
LOG_DIR: Path = Path(__file__).parent.parent.parent / "logs"
|
||||||
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
LOG_FILE: Path = LOG_DIR / "agent.log"
|
||||||
|
|
||||||
|
|
||||||
|
class JSONFormatter(logging.Formatter):
|
||||||
|
"""Formats log records as JSON objects for structured logging."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
log_data = {
|
||||||
|
"timestamp": self.formatTime(record, self.datefmt),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
}
|
||||||
|
if record.exc_info:
|
||||||
|
log_data["exception"] = self.formatException(record.exc_info)
|
||||||
|
return json.dumps(log_data)
|
||||||
|
|
||||||
|
|
||||||
|
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=5 * 1024 * 1024, backupCount=5)
|
||||||
|
file_handler.setFormatter(JSONFormatter())
|
||||||
|
|
||||||
|
stream_handler = logging.StreamHandler()
|
||||||
|
stream_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
handlers=[file_handler, stream_handler]
|
||||||
|
)
|
||||||
|
logger: logging.Logger = logging.getLogger("coding-agent")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Ensure we're in the repo directory
|
||||||
|
repo_root: Path = Path(__file__).parent.parent.parent
|
||||||
|
if Path.cwd() != repo_root:
|
||||||
|
os.chdir(repo_root)
|
||||||
|
logger.info(f"Changed directory to {repo_root}")
|
||||||
|
|
||||||
|
# Initialize Gitea components
|
||||||
|
client: GiteaClient = GiteaClient()
|
||||||
|
tools: GiteaTools = GiteaTools(client)
|
||||||
|
model_name: str = AGENT_MODEL_ID
|
||||||
|
|
||||||
|
# Initialize orchestrator
|
||||||
|
orchestrator: AgentOrchestrator = AgentOrchestrator(client, tools, model_name, AGENT_MAX_RETRIES)
|
||||||
|
|
||||||
|
logger.info("--- Autonomous Coding Agent Active ---")
|
||||||
|
logger.info(f"Model: {model_name}")
|
||||||
|
logger.info(f"Max retries: {AGENT_MAX_RETRIES}")
|
||||||
|
logger.info(f"Checking Gitea at {client.base_url}")
|
||||||
|
logger.info("Press Ctrl+C to stop.")
|
||||||
|
|
||||||
|
consecutive_errors: int = 0
|
||||||
|
max_consecutive_errors: int = 5
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
logger.info("=== Checking for pending tasks ===")
|
||||||
|
|
||||||
|
# Use orchestrator to poll and dispatch
|
||||||
|
await orchestrator.poll_and_dispatch()
|
||||||
|
|
||||||
|
# Reset error counter on successful run
|
||||||
|
consecutive_errors = 0
|
||||||
|
|
||||||
|
logger.info("=== Waiting 60 seconds before next check ===")
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Agent stopped.")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
consecutive_errors += 1
|
||||||
|
logger.error(f"Error in main loop (attempt {consecutive_errors}/{max_consecutive_errors}): {e}")
|
||||||
|
|
||||||
|
# If too many consecutive errors, wait longer
|
||||||
|
if consecutive_errors >= max_consecutive_errors:
|
||||||
|
logger.error(f"Too many consecutive errors ({max_consecutive_errors}). Waiting 5 minutes before retry.")
|
||||||
|
await asyncio.sleep(300)
|
||||||
|
consecutive_errors = 0
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
||||||
|
|
||||||
|
def start_agent() -> None:
|
||||||
|
"""Entry point for uv run."""
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
[project]
|
||||||
|
name = "gitea-agent"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "An AI agent that automates Gitea tasks."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"httpx>=0.28.1",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
"openai>=1.0.0",
|
||||||
|
"pydantic>=2.13.4",
|
||||||
|
"pydantic-settings>=2.0.0",
|
||||||
|
"lmstudio>=1.5.0",
|
||||||
|
# Research tools
|
||||||
|
"duckduckgo-search>=6.0",
|
||||||
|
"trafilatura>=1.12",
|
||||||
|
"readability-lxml>=0.8",
|
||||||
|
"markdownify>=0.13",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
start-agent = "main:start_agent"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
py-modules = ["main"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["core", "gitea"]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=9.1.1",
|
||||||
|
"ty>=0.0.55",
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Tests package.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, AsyncMock, patch
|
||||||
|
from pathlib import Path
|
||||||
|
from main import JSONFormatter
|
||||||
|
from gitea.tools.coding_tools import CodingTools
|
||||||
|
from core.dispatcher import AgentDispatcher
|
||||||
|
from core.queue import WorkItem
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
from gitea.tools.gitea_tools import GiteaTools
|
||||||
|
from gitea.models import IssueModel, PullRequestModel
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.anyio
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_formatter() -> None:
|
||||||
|
formatter = JSONFormatter()
|
||||||
|
record = logging.LogRecord(
|
||||||
|
name="test-logger",
|
||||||
|
level=logging.INFO,
|
||||||
|
pathname="test.py",
|
||||||
|
lineno=10,
|
||||||
|
msg="Test log message",
|
||||||
|
args=(),
|
||||||
|
exc_info=None
|
||||||
|
)
|
||||||
|
formatted = formatter.format(record)
|
||||||
|
data = json.loads(formatted)
|
||||||
|
assert data["level"] == "INFO"
|
||||||
|
assert data["logger"] == "test-logger"
|
||||||
|
assert data["message"] == "Test log message"
|
||||||
|
assert "timestamp" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_file_limit(tmp_path: Path) -> None:
|
||||||
|
# Create a large file
|
||||||
|
large_file = tmp_path / "large_file.txt"
|
||||||
|
lines = [f"Line {i}\n" for i in range(1, 400)]
|
||||||
|
large_file.write_text("".join(lines), encoding="utf-8")
|
||||||
|
|
||||||
|
coding_tools = CodingTools(str(tmp_path))
|
||||||
|
# Test read with default limit (250)
|
||||||
|
result = coding_tools.read_file("large_file.txt", offset=1)
|
||||||
|
result_lines = [line.strip() for line in result.splitlines() if line.strip()]
|
||||||
|
assert len(result_lines) == 250
|
||||||
|
assert result_lines[0] == "1: Line 1"
|
||||||
|
assert result_lines[-1] == "250: Line 250"
|
||||||
|
|
||||||
|
# Test read with custom limit
|
||||||
|
result_custom = coding_tools.read_file("large_file.txt", offset=10, limit=10)
|
||||||
|
result_custom_lines = [line.strip() for line in result_custom.splitlines() if line.strip()]
|
||||||
|
assert len(result_custom_lines) == 10
|
||||||
|
assert result_custom_lines[0] == "10: Line 10"
|
||||||
|
assert result_custom_lines[-1] == "19: Line 19"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_verification_commands(tmp_path: Path) -> None:
|
||||||
|
agents_md = tmp_path / "AGENTS.md"
|
||||||
|
agents_md.write_text(
|
||||||
|
"# Agent Instructions\n\n"
|
||||||
|
"## Verification\n"
|
||||||
|
"Please verify your changes with:\n"
|
||||||
|
"```bash\n"
|
||||||
|
"pytest -v\n"
|
||||||
|
"npm run lint\n"
|
||||||
|
"```\n\n"
|
||||||
|
"## Something Else\n"
|
||||||
|
"```bash\n"
|
||||||
|
"ignored command\n"
|
||||||
|
"```\n",
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
coding_tools = CodingTools(str(tmp_path))
|
||||||
|
commands = coding_tools._parse_verification_commands()
|
||||||
|
assert commands == ["pytest -v", "npm run lint"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_verification_failure(tmp_path: Path) -> None:
|
||||||
|
agents_md = tmp_path / "AGENTS.md"
|
||||||
|
agents_md.write_text(
|
||||||
|
"## Verification\n"
|
||||||
|
"```bash\n"
|
||||||
|
"false\n"
|
||||||
|
"```\n",
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
coding_tools = CodingTools(str(tmp_path))
|
||||||
|
success, log_msg = coding_tools.run_verification()
|
||||||
|
assert not success
|
||||||
|
assert "false" in log_msg
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.dispatcher.CodingAgent")
|
||||||
|
@patch("core.dispatcher.PlanningAgent")
|
||||||
|
async def test_dispatch_planning_and_coding_phases(mock_planning_class: MagicMock, mock_coding_class: MagicMock) -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
# Mock no existing PRs
|
||||||
|
mock_client.list_repo_pull_requests.return_value = []
|
||||||
|
mock_client.get_issue_comments.return_value = []
|
||||||
|
|
||||||
|
from gitea.models import UserModel
|
||||||
|
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||||
|
|
||||||
|
mock_pr = PullRequestModel(
|
||||||
|
number=42,
|
||||||
|
title="fix bug",
|
||||||
|
body="bug details",
|
||||||
|
user=UserModel(login="meeks-ai")
|
||||||
|
)
|
||||||
|
mock_client.get_pull_request.return_value = mock_pr
|
||||||
|
mock_client.get_pull_request_diff.return_value = "diff"
|
||||||
|
mock_client.get_pull_request_comments.return_value = []
|
||||||
|
mock_client.get_pull_request_files.return_value = []
|
||||||
|
mock_client.get_pr_reviews.return_value = []
|
||||||
|
|
||||||
|
# Mock agent instances
|
||||||
|
mock_planning_agent = MagicMock()
|
||||||
|
mock_planning_agent.run_with_tools = AsyncMock(return_value="Plan: Modify file A")
|
||||||
|
mock_planning_class.return_value = mock_planning_agent
|
||||||
|
|
||||||
|
mock_coding_agent = MagicMock()
|
||||||
|
mock_coding_agent.run_with_tools = AsyncMock(return_value="PR #1 Created")
|
||||||
|
mock_coding_class.return_value = mock_coding_agent
|
||||||
|
|
||||||
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
|
|
||||||
|
work_item = WorkItem(
|
||||||
|
repo_full_name="meeks/repo1",
|
||||||
|
task_type="pr",
|
||||||
|
task_number=42,
|
||||||
|
task_info=mock_pr,
|
||||||
|
priority=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# We mock os.path.isdir to return True so os.chdir won't fail or crash in test
|
||||||
|
with patch("os.path.isdir", return_value=True), patch("os.chdir"):
|
||||||
|
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0] == "PR #1 Created"
|
||||||
|
|
||||||
|
# Verify both agents ran
|
||||||
|
assert mock_planning_agent.run_with_tools.call_count == 1
|
||||||
|
assert mock_coding_agent.run_with_tools.call_count == 1
|
||||||
|
|
||||||
|
# Verify planning prompt was passed correct context
|
||||||
|
planning_call_args = mock_planning_agent.run_with_tools.call_args[0]
|
||||||
|
assert "PHASE 1: PLANNING PHASE" in planning_call_args[0]
|
||||||
|
|
||||||
|
# Verify coding prompt received the generated plan
|
||||||
|
coding_call_args = mock_coding_agent.run_with_tools.call_args[0]
|
||||||
|
assert "PHASE 2: EXECUTION/CODING PHASE" in coding_call_args[0]
|
||||||
|
assert "Plan: Modify file A" in coding_call_args[0]
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_client_list_repo_issues() -> None:
|
||||||
|
client: GiteaClient = GiteaClient()
|
||||||
|
with patch("httpx.Client.get") as mock_get:
|
||||||
|
mock_response: MagicMock = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = []
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
# Test default parameter ("open")
|
||||||
|
client.list_repo_issues("owner", "repo")
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
args, _ = mock_get.call_args
|
||||||
|
assert "type=issues" in args[0]
|
||||||
|
assert "state=open" in args[0]
|
||||||
|
|
||||||
|
mock_get.reset_mock()
|
||||||
|
|
||||||
|
# Test custom parameter ("closed")
|
||||||
|
client.list_repo_issues("owner", "repo", state="closed")
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
args, _ = mock_get.call_args
|
||||||
|
assert "type=issues" in args[0]
|
||||||
|
assert "state=closed" in args[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_client_list_repo_pull_requests() -> None:
|
||||||
|
client: GiteaClient = GiteaClient()
|
||||||
|
with patch("httpx.Client.get") as mock_get:
|
||||||
|
mock_response: MagicMock = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = []
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
# Test default parameter ("open")
|
||||||
|
client.list_repo_pull_requests("owner", "repo")
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
args, _ = mock_get.call_args
|
||||||
|
assert "state=open" in args[0]
|
||||||
|
|
||||||
|
mock_get.reset_mock()
|
||||||
|
|
||||||
|
# Test custom parameter ("closed")
|
||||||
|
client.list_repo_pull_requests("owner", "repo", state="closed")
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
args, _ = mock_get.call_args
|
||||||
|
assert "state=closed" in args[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_client_list_assigned_issues() -> None:
|
||||||
|
client: GiteaClient = GiteaClient()
|
||||||
|
user_mock: MagicMock = MagicMock()
|
||||||
|
user_mock.login = "testuser"
|
||||||
|
|
||||||
|
with patch.object(client, "get_authenticated_user", return_value=user_mock), \
|
||||||
|
patch("httpx.get") as mock_get:
|
||||||
|
mock_response: MagicMock = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = []
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
client.list_assigned_issues("owner", "repo")
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
args, _ = mock_get.call_args
|
||||||
|
assert "type=issues" in args[0]
|
||||||
|
assert "state=open" in args[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_client_list_assigned_pull_requests() -> None:
|
||||||
|
client: GiteaClient = GiteaClient()
|
||||||
|
user_mock: MagicMock = MagicMock()
|
||||||
|
user_mock.login = "testuser"
|
||||||
|
|
||||||
|
with patch.object(client, "get_authenticated_user", return_value=user_mock), \
|
||||||
|
patch("httpx.get") as mock_get:
|
||||||
|
mock_response: MagicMock = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = [
|
||||||
|
{"number": 1, "title": "PR 1", "assignee": {"login": "testuser"}, "user": {"login": "otheruser"}},
|
||||||
|
{"number": 2, "title": "PR 2", "assignee": None, "user": {"login": "testuser"}},
|
||||||
|
{"number": 3, "title": "PR 3", "assignee": {"login": "otheruser"}, "user": {"login": "otheruser"}}
|
||||||
|
]
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
res = client.list_assigned_pull_requests("owner", "repo")
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
assert len(res) == 2
|
||||||
|
numbers = [pr.number for pr in res]
|
||||||
|
assert 1 in numbers
|
||||||
|
assert 2 in numbers
|
||||||
|
assert 3 not in numbers
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_client_list_unread_notifications() -> None:
|
||||||
|
client: GiteaClient = GiteaClient()
|
||||||
|
with patch("httpx.Client.get") as mock_get:
|
||||||
|
mock_response: MagicMock = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = [
|
||||||
|
{"id": 1, "repository": {"owner": {"login": "meeks"}}},
|
||||||
|
{"id": 2, "repository": {"owner": {"login": "other"}}},
|
||||||
|
]
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
# Test without since
|
||||||
|
res = client.list_unread_notifications()
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
_, kwargs = mock_get.call_args
|
||||||
|
assert kwargs.get("params") == {"all": "false"}
|
||||||
|
assert len(res) == 1
|
||||||
|
assert res[0]["id"] == 1
|
||||||
|
|
||||||
|
mock_get.reset_mock()
|
||||||
|
|
||||||
|
# Test with since
|
||||||
|
res = client.list_unread_notifications(since="2026-06-30T21:41:16+02:00")
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
_, kwargs = mock_get.call_args
|
||||||
|
assert kwargs.get("params") == {"all": "false", "since": "2026-06-30T21:41:16+02:00"}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
import pytest
|
||||||
|
from gitea.tools.coding_tools import CodingTools
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_files(tmp_path: Path) -> None:
|
||||||
|
d: Path = tmp_path / "sub"
|
||||||
|
d.mkdir()
|
||||||
|
f: Path = d / "hello.txt"
|
||||||
|
f.write_text("content")
|
||||||
|
|
||||||
|
res: str = CodingTools().list_files(str(d))
|
||||||
|
assert "hello.txt" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_files_error() -> None:
|
||||||
|
res: str = CodingTools().list_files("/nonexistent/directory/path/here")
|
||||||
|
assert "Error listing files" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_file(tmp_path: Path) -> None:
|
||||||
|
f: Path = tmp_path / "test.txt"
|
||||||
|
f.write_text("line1\nline2\nline3\n")
|
||||||
|
|
||||||
|
res: str = CodingTools().read_file(str(f), offset=1, limit=2)
|
||||||
|
assert "1: line1" in res
|
||||||
|
assert "2: line2" in res
|
||||||
|
assert "3: line3" not in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_file_empty_or_out_of_bounds(tmp_path: Path) -> None:
|
||||||
|
f: Path = tmp_path / "test.txt"
|
||||||
|
f.write_text("")
|
||||||
|
res: str = CodingTools().read_file(str(f), offset=10, limit=2)
|
||||||
|
assert res == "File is empty or offset out of bounds."
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_file_error() -> None:
|
||||||
|
res: str = CodingTools().read_file("/nonexistent/file/path/here")
|
||||||
|
assert "Error reading file" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_file(tmp_path: Path) -> None:
|
||||||
|
f: Path = tmp_path / "new_dir" / "test.txt"
|
||||||
|
res: str = CodingTools().write_file(str(f), "content")
|
||||||
|
assert "written successfully" in res
|
||||||
|
assert f.read_text() == "content"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_file_error() -> None:
|
||||||
|
res: str = CodingTools().write_file("", "content")
|
||||||
|
assert "Error writing file" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file(tmp_path: Path) -> None:
|
||||||
|
f: Path = tmp_path / "test.txt"
|
||||||
|
f.write_text("hello world")
|
||||||
|
res: str = CodingTools().edit_file(str(f), "world", "there")
|
||||||
|
assert "edited successfully" in res
|
||||||
|
assert f.read_text() == "hello there"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_not_found(tmp_path: Path) -> None:
|
||||||
|
f: Path = tmp_path / "test.txt"
|
||||||
|
f.write_text("hello world")
|
||||||
|
res: str = CodingTools().edit_file(str(f), "nonexistent", "there")
|
||||||
|
assert "not found" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_error() -> None:
|
||||||
|
res: str = CodingTools().edit_file("/nonexistent/file/path/here", "world", "there")
|
||||||
|
assert "Error editing file" in res
|
||||||
|
|
||||||
|
|
||||||
|
@patch("subprocess.Popen")
|
||||||
|
def test_run_command_success(mock_popen: MagicMock) -> None:
|
||||||
|
mock_process: MagicMock = MagicMock()
|
||||||
|
mock_process.communicate.return_value = ("output_stdout", "output_stderr")
|
||||||
|
mock_process.returncode = 0
|
||||||
|
mock_popen.return_value = mock_process
|
||||||
|
|
||||||
|
res: str = CodingTools().run_command("echo hello")
|
||||||
|
assert "output_stdout" in res
|
||||||
|
assert "output_stderr" in res
|
||||||
|
|
||||||
|
|
||||||
|
@patch("subprocess.Popen")
|
||||||
|
def test_run_command_failure(mock_popen: MagicMock) -> None:
|
||||||
|
mock_process: MagicMock = MagicMock()
|
||||||
|
mock_process.communicate.return_value = ("output_stdout", "output_stderr")
|
||||||
|
mock_process.returncode = 1
|
||||||
|
mock_popen.return_value = mock_process
|
||||||
|
|
||||||
|
res: str = CodingTools().run_command("false")
|
||||||
|
assert "Command failed with exit code 1" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_command_error() -> None:
|
||||||
|
with patch("subprocess.Popen", side_effect=ValueError("Invalid run")):
|
||||||
|
res: str = CodingTools().run_command("echo")
|
||||||
|
assert "Error running command" in res
|
||||||
|
|
||||||
|
|
||||||
|
@patch("subprocess.Popen")
|
||||||
|
def test_run_command_timeout(mock_popen: MagicMock) -> None:
|
||||||
|
mock_process: MagicMock = MagicMock()
|
||||||
|
mock_process.communicate.side_effect = [
|
||||||
|
subprocess.TimeoutExpired(cmd="test", timeout=1),
|
||||||
|
("stdout_after_kill", "stderr_after_kill")
|
||||||
|
]
|
||||||
|
mock_popen.return_value = mock_process
|
||||||
|
|
||||||
|
res: str = CodingTools().run_command("hang_cmd", timeout=1)
|
||||||
|
assert "Command timed out after 1 seconds" in res
|
||||||
|
assert "stdout_after_kill" in res
|
||||||
|
mock_process.kill.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("subprocess.Popen")
|
||||||
|
def test_grep_search_success(mock_popen: MagicMock) -> None:
|
||||||
|
mock_process: MagicMock = MagicMock()
|
||||||
|
mock_process.communicate.return_value = ("match_line", "")
|
||||||
|
mock_process.returncode = 0
|
||||||
|
mock_popen.return_value = mock_process
|
||||||
|
|
||||||
|
res: str = CodingTools().grep_search("pattern", "/path")
|
||||||
|
assert res == "match_line"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("subprocess.Popen")
|
||||||
|
def test_grep_search_no_matches(mock_popen: MagicMock) -> None:
|
||||||
|
mock_process: MagicMock = MagicMock()
|
||||||
|
mock_process.communicate.return_value = ("", "")
|
||||||
|
mock_process.returncode = 1
|
||||||
|
mock_popen.return_value = mock_process
|
||||||
|
|
||||||
|
res: str = CodingTools().grep_search("pattern", "/path")
|
||||||
|
assert "No matches found" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_grep_search_error() -> None:
|
||||||
|
with patch("subprocess.Popen", side_effect=ValueError("Invalid run")):
|
||||||
|
res: str = CodingTools().grep_search("pattern")
|
||||||
|
assert "Error during grep search" in res
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, AsyncMock, patch, ANY
|
||||||
|
from core.dispatcher import AgentDispatcher
|
||||||
|
from core.queue import WorkItem
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
from gitea.tools.gitea_tools import GiteaTools
|
||||||
|
from gitea.models import PullRequestModel, IssueModel, CommentModel, UserModel
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.anyio
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dispatch_skips_issue_with_existing_pr() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
# Mock list_repo_pull_requests to return a PR that closes issue #42
|
||||||
|
pr = PullRequestModel(
|
||||||
|
number=101,
|
||||||
|
title="fix: resolve bug",
|
||||||
|
body="closes #42"
|
||||||
|
)
|
||||||
|
mock_client.list_repo_pull_requests.return_value = [pr]
|
||||||
|
|
||||||
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
|
|
||||||
|
work_item = WorkItem(
|
||||||
|
repo_full_name="meeks/repo1",
|
||||||
|
task_type="issue",
|
||||||
|
task_number=42,
|
||||||
|
task_info=IssueModel(number=42),
|
||||||
|
priority=0
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert "SKIP: A pull request (PR #101) addressing issue #42 already exists" in results[0]
|
||||||
|
mock_client.list_repo_pull_requests.assert_called_once_with("meeks", "repo1")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.dispatcher.CoordinatorAgent")
|
||||||
|
async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock) -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
# Mock list_repo_pull_requests to return PRs that don't address issue #42
|
||||||
|
pr = PullRequestModel(
|
||||||
|
number=101,
|
||||||
|
title="feat: add something",
|
||||||
|
body="closes #99"
|
||||||
|
)
|
||||||
|
mock_client.list_repo_pull_requests.return_value = [pr]
|
||||||
|
mock_client.get_issue_comments.return_value = []
|
||||||
|
|
||||||
|
# Mock CoordinatorAgent invoking propose_plan tool
|
||||||
|
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||||
|
coord_tools.propose_plan(plan="- change X", issue_number=42)
|
||||||
|
return "Agent proposed plan."
|
||||||
|
|
||||||
|
mock_coord_instance = MagicMock()
|
||||||
|
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
||||||
|
mock_coord_class.return_value = mock_coord_instance
|
||||||
|
|
||||||
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
|
|
||||||
|
work_item = WorkItem(
|
||||||
|
repo_full_name="meeks/repo1",
|
||||||
|
task_type="issue",
|
||||||
|
task_number=42,
|
||||||
|
task_info=IssueModel(number=42, title="test", body="test desc"),
|
||||||
|
priority=0
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0]
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.dispatcher.CodingAgent")
|
||||||
|
async def test_build_pr_mission_injects_issue_context(mock_agent_class: MagicMock) -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
# Mock get_pull_request, get_pull_request_diff, etc.
|
||||||
|
pr = PullRequestModel(
|
||||||
|
number=101,
|
||||||
|
title="fix: resolve bug",
|
||||||
|
body="closes #42",
|
||||||
|
head={"ref": "branch1"},
|
||||||
|
base={"ref": "master"}
|
||||||
|
)
|
||||||
|
mock_client.get_pull_request.return_value = pr
|
||||||
|
mock_client.get_pull_request_diff.return_value = "diff context"
|
||||||
|
mock_client.get_pull_request_files.return_value = []
|
||||||
|
mock_client.get_pull_request_comments.return_value = []
|
||||||
|
|
||||||
|
# Mock the connected issue and its comments
|
||||||
|
issue = IssueModel(number=42, title="bug description")
|
||||||
|
mock_client.get_issue.return_value = issue
|
||||||
|
|
||||||
|
comment = CommentModel(id=1, body="First comment")
|
||||||
|
mock_client.get_issue_comments.return_value = [comment]
|
||||||
|
|
||||||
|
# Mock CodingAgent
|
||||||
|
mock_agent_instance = MagicMock()
|
||||||
|
mock_agent_instance.run_with_tools = AsyncMock(return_value="PR Reviewed.")
|
||||||
|
mock_agent_class.return_value = mock_agent_instance
|
||||||
|
|
||||||
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
|
|
||||||
|
work_item = WorkItem(
|
||||||
|
repo_full_name="meeks/repo1",
|
||||||
|
task_type="pr",
|
||||||
|
task_number=101,
|
||||||
|
task_info=PullRequestModel(number=101, title="fix: resolve bug", body="closes #42"),
|
||||||
|
priority=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# We will patch the dispatcher._build_pr_mission output validation
|
||||||
|
mission = dispatcher._build_pr_mission(work_item)
|
||||||
|
|
||||||
|
assert "CONNECTED ISSUE CONTEXT" in mission
|
||||||
|
assert "Connected Issue #42" in mission
|
||||||
|
assert "bug description" in mission
|
||||||
|
assert "First comment" in mission
|
||||||
|
|
||||||
|
mock_client.get_issue.assert_called_once_with("meeks", "repo1", 42)
|
||||||
|
mock_client.get_issue_comments.assert_called_once_with("meeks", "repo1", 42)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_find_pr_for_issue_by_branch() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
|
|
||||||
|
# 1. Matches fix/issue-42-some-desc
|
||||||
|
pr1 = PullRequestModel(number=102, head={"ref": "fix/issue-42-some-desc"})
|
||||||
|
mock_client.list_repo_pull_requests.return_value = [pr1]
|
||||||
|
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
|
||||||
|
|
||||||
|
# 2. Matches fix/42
|
||||||
|
pr2 = PullRequestModel(number=102, head={"ref": "fix/42"})
|
||||||
|
mock_client.list_repo_pull_requests.return_value = [pr2]
|
||||||
|
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
|
||||||
|
|
||||||
|
# 3. Matches fix-42_desc
|
||||||
|
pr3 = PullRequestModel(number=102, head={"ref": "fix-42_desc"})
|
||||||
|
mock_client.list_repo_pull_requests.return_value = [pr3]
|
||||||
|
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None
|
||||||
|
|
||||||
|
# 4. Does NOT match fix/142
|
||||||
|
pr4 = PullRequestModel(number=102, head={"ref": "fix/142"})
|
||||||
|
mock_client.list_repo_pull_requests.return_value = [pr4]
|
||||||
|
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is None
|
||||||
|
|
||||||
|
# 5. Does NOT match fix/421
|
||||||
|
pr5 = PullRequestModel(number=102, head={"ref": "fix/421"})
|
||||||
|
mock_client.list_repo_pull_requests.return_value = [pr5]
|
||||||
|
assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def test_find_pr_for_issue_by_raw_mention() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
# PR body mentions #42
|
||||||
|
pr = PullRequestModel(
|
||||||
|
number=103,
|
||||||
|
title="some fix",
|
||||||
|
body="This is for #42 to fix the bug",
|
||||||
|
head={"ref": "some-branch"}
|
||||||
|
)
|
||||||
|
mock_client.list_repo_pull_requests.return_value = [pr]
|
||||||
|
|
||||||
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
|
res = dispatcher._find_pr_for_issue("meeks/repo1", 42)
|
||||||
|
assert res is not None
|
||||||
|
assert res.number == 103
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dispatch_skips_already_reviewed_pr() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
from gitea.models import UserModel
|
||||||
|
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||||
|
pr = PullRequestModel(
|
||||||
|
number=104,
|
||||||
|
title="already reviewed PR",
|
||||||
|
body="closes #42",
|
||||||
|
user=UserModel(login="meeks-ai")
|
||||||
|
)
|
||||||
|
mock_client.get_pull_request.return_value = pr
|
||||||
|
mock_client.get_pull_request_diff.return_value = "diff"
|
||||||
|
mock_client.get_pull_request_comments.return_value = [
|
||||||
|
CommentModel(id=1, body="Reviewed by AI Agent: Looks good.")
|
||||||
|
]
|
||||||
|
|
||||||
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
|
|
||||||
|
work_item = WorkItem(
|
||||||
|
repo_full_name="meeks/repo1",
|
||||||
|
task_type="pr",
|
||||||
|
task_number=104,
|
||||||
|
task_info=PullRequestModel(number=104, title="already reviewed PR", body="closes #42"),
|
||||||
|
priority=0
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
|
assert len(results) == 1
|
||||||
|
assert "SKIP: PR #104 has already been addressed by AI" in results[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ── _is_awaiting_reply tests ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_comment(login: str, body: str) -> CommentModel:
|
||||||
|
from gitea.models import UserModel
|
||||||
|
user = UserModel(login=login)
|
||||||
|
return CommentModel(id=1, body=body, user=user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_awaiting_reply_no_comments() -> None:
|
||||||
|
dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock())
|
||||||
|
assert dispatcher._is_awaiting_reply([]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_awaiting_reply_no_question() -> None:
|
||||||
|
dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock())
|
||||||
|
comments = [_make_comment("meeks-ai", "I will fix this now.")]
|
||||||
|
assert dispatcher._is_awaiting_reply(comments) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_awaiting_reply_agent_question_no_human_reply() -> None:
|
||||||
|
dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock())
|
||||||
|
body = "Should I use approach A or B?\n<!-- agent:awaiting-reply -->"
|
||||||
|
comments = [_make_comment("meeks-ai", body)]
|
||||||
|
assert dispatcher._is_awaiting_reply(comments) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_awaiting_reply_agent_question_human_replied() -> None:
|
||||||
|
dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock())
|
||||||
|
body = "Should I use approach A or B?\n<!-- agent:awaiting-reply -->"
|
||||||
|
comments = [
|
||||||
|
_make_comment("meeks-ai", body),
|
||||||
|
_make_comment("michael", "Use approach A please."),
|
||||||
|
]
|
||||||
|
assert dispatcher._is_awaiting_reply(comments) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_awaiting_reply_no_marker_not_detected() -> None:
|
||||||
|
"""Agent asked a question but forgot the marker — should NOT be skipped."""
|
||||||
|
dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock())
|
||||||
|
comments = [_make_comment("meeks-ai", "Should I use approach A or B?")]
|
||||||
|
assert dispatcher._is_awaiting_reply(comments) is False
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.dispatcher.CoordinatorAgent")
|
||||||
|
async def test_dispatch_proposes_plan(mock_coord_class: MagicMock) -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
mock_client.list_repo_pull_requests.return_value = []
|
||||||
|
mock_client.get_issue_comments.return_value = []
|
||||||
|
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||||
|
|
||||||
|
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||||
|
coord_tools.propose_plan(plan="- Add endpoint\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->", issue_number=42)
|
||||||
|
return "Agent proposed plan."
|
||||||
|
mock_coord_instance = MagicMock()
|
||||||
|
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
||||||
|
mock_coord_class.return_value = mock_coord_instance
|
||||||
|
|
||||||
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
|
work_item = WorkItem(
|
||||||
|
repo_full_name="meeks/repo1",
|
||||||
|
task_type="issue",
|
||||||
|
task_number=42,
|
||||||
|
task_info=IssueModel(number=42, title="add X", body=""),
|
||||||
|
priority=0
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
|
assert len(results) == 1
|
||||||
|
assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0]
|
||||||
|
mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "### Proposed Implementation Plan\n\n- Add endpoint\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->\n\nIs this plan ok for implementation or do you have any comments/changes?\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.dispatcher.CoordinatorAgent")
|
||||||
|
async def test_dispatch_answers_question(mock_coord_class: MagicMock) -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
mock_client.list_repo_pull_requests.return_value = []
|
||||||
|
mock_client.get_issue_comments.return_value = []
|
||||||
|
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||||
|
|
||||||
|
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||||
|
coord_tools.answer_question(answer="X works by doing Y.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->", issue_number=42)
|
||||||
|
return "Agent answered question."
|
||||||
|
mock_coord_instance = MagicMock()
|
||||||
|
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
||||||
|
mock_coord_class.return_value = mock_coord_instance
|
||||||
|
|
||||||
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
|
work_item = WorkItem(
|
||||||
|
repo_full_name="meeks/repo1",
|
||||||
|
task_type="issue",
|
||||||
|
task_number=42,
|
||||||
|
task_info=IssueModel(number=42, title="how does X work", body=""),
|
||||||
|
priority=0
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
|
assert len(results) == 1
|
||||||
|
assert "POSTED_COMMENT: ANSWER_QUESTION" in results[0]
|
||||||
|
mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "X works by doing Y.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->\n\nIs this answer satisfactory?\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.dispatcher.CoordinatorAgent")
|
||||||
|
async def test_dispatch_closes_issue_on_satisfaction(mock_coord_class: MagicMock) -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
mock_client.list_repo_pull_requests.return_value = []
|
||||||
|
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||||
|
|
||||||
|
# Human comments indicating satisfaction after our answer
|
||||||
|
mock_client.get_issue_comments.return_value = [
|
||||||
|
_make_comment("meeks-ai", "Here is the answer.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->"),
|
||||||
|
_make_comment("michael", "Yes, thanks! That makes sense.")
|
||||||
|
]
|
||||||
|
|
||||||
|
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||||
|
coord_tools.close_issue(comment="Closing the issue now. Let me know if you need anything else!", issue_number=42)
|
||||||
|
return "Agent closed issue."
|
||||||
|
mock_coord_instance = MagicMock()
|
||||||
|
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
||||||
|
mock_coord_class.return_value = mock_coord_instance
|
||||||
|
|
||||||
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
|
work_item = WorkItem(
|
||||||
|
repo_full_name="meeks/repo1",
|
||||||
|
task_type="issue",
|
||||||
|
task_number=42,
|
||||||
|
task_info=IssueModel(number=42, title="how does X work", body=""),
|
||||||
|
priority=0
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
|
assert len(results) == 1
|
||||||
|
assert "CLOSED_ISSUE: Issue #42 closed." in results[0]
|
||||||
|
mock_client.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)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
@patch("core.dispatcher.CodingAgent")
|
||||||
|
@patch("core.dispatcher.CoordinatorAgent")
|
||||||
|
async def test_dispatch_executes_approved_plan_and_creates_wip_pr(mock_coord_class: MagicMock, mock_coding_class: MagicMock, mock_run: MagicMock) -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
mock_client.list_repo_pull_requests.return_value = []
|
||||||
|
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||||
|
mock_client.get_issue_comments.return_value = [
|
||||||
|
_make_comment("meeks-ai", "### Proposed Plan\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
|
||||||
|
_make_comment("michael", "looks good, go ahead")
|
||||||
|
]
|
||||||
|
|
||||||
|
# Return PR object on creation
|
||||||
|
mock_pr = PullRequestModel(number=105, title="WIP: add X", html_url="http://gitea/pr/105", head={"ref": "fix/issue-42-add-x"})
|
||||||
|
mock_client.create_pull_request.return_value = mock_pr
|
||||||
|
|
||||||
|
# Mock planning agent deciding EXECUTE_PLAN
|
||||||
|
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||||
|
coord_tools.start_implementation(approved_plan="Step 1. Code X", issue_number=42)
|
||||||
|
return "Agent decided execute plan."
|
||||||
|
mock_coord_class.return_value.decide_action = AsyncMock(side_effect=mock_decide)
|
||||||
|
|
||||||
|
# Mock coding agent executing plan
|
||||||
|
mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Completed Successfully.")
|
||||||
|
|
||||||
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
|
work_item = WorkItem(
|
||||||
|
repo_full_name="meeks/repo1",
|
||||||
|
task_type="issue",
|
||||||
|
task_number=42,
|
||||||
|
task_info=IssueModel(number=42, title="add X", body=""),
|
||||||
|
priority=0
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0] == "PR Completed Successfully."
|
||||||
|
|
||||||
|
# Verify subprocess git commands
|
||||||
|
mock_run.assert_any_call(["git", "checkout", "master"], cwd=ANY, check=True)
|
||||||
|
mock_run.assert_any_call(["git", "checkout", "-b", "fix/issue-42-add-x"], cwd=ANY, check=True)
|
||||||
|
|
||||||
|
# Verify WIP PR creation and starting comment
|
||||||
|
mock_client.create_pull_request.assert_called_once_with(
|
||||||
|
"meeks", "repo1", head="fix/issue-42-add-x", base="master", title="WIP: add X", description="Work in progress for issue #42."
|
||||||
|
)
|
||||||
|
mock_client.add_comment.assert_any_call("meeks", "repo1", 42, "Started work on PR #105 (http://gitea/pr/105).")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
@patch("core.dispatcher.CodingAgent")
|
||||||
|
@patch("core.dispatcher.CoordinatorAgent")
|
||||||
|
async def test_dispatch_resumes_wip_pr(mock_coord_class: MagicMock, mock_coding_class: MagicMock, mock_run: MagicMock) -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||||
|
|
||||||
|
# Existing WIP PR addressing issue #42
|
||||||
|
wip_pr = PullRequestModel(
|
||||||
|
number=105,
|
||||||
|
title="WIP: add X",
|
||||||
|
state="open",
|
||||||
|
head={"ref": "fix/issue-42-add-x"}
|
||||||
|
)
|
||||||
|
mock_client.list_repo_pull_requests.return_value = [wip_pr]
|
||||||
|
mock_client.get_pr_reviews.return_value = []
|
||||||
|
|
||||||
|
mock_client.get_issue_comments.return_value = [
|
||||||
|
_make_comment("meeks-ai", "### Proposed Plan\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
|
||||||
|
_make_comment("michael", "looks good, go ahead")
|
||||||
|
]
|
||||||
|
mock_client.get_pull_request_comments.return_value = []
|
||||||
|
|
||||||
|
# Mock planning agent deciding EXECUTE_PLAN
|
||||||
|
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||||
|
coord_tools.start_implementation(approved_plan="Step 1. Resume coding", issue_number=42)
|
||||||
|
return "Agent decided execute plan."
|
||||||
|
mock_coord_class.return_value.decide_action = AsyncMock(side_effect=mock_decide)
|
||||||
|
|
||||||
|
# Mock coding agent executing plan
|
||||||
|
mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Updated Successfully.")
|
||||||
|
|
||||||
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
|
work_item = WorkItem(
|
||||||
|
repo_full_name="meeks/repo1",
|
||||||
|
task_type="issue",
|
||||||
|
task_number=42,
|
||||||
|
task_info=IssueModel(number=42, title="add X", body=""),
|
||||||
|
priority=0
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0] == "PR Updated Successfully."
|
||||||
|
|
||||||
|
# Ensure create_pull_request was NOT called since it already exists
|
||||||
|
mock_client.create_pull_request.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dispatch_skips_pr_if_not_requested_reviewer() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||||
|
|
||||||
|
# PR authored by michael, requested reviewers is empty (agent not requested)
|
||||||
|
pr_detail = PullRequestModel(
|
||||||
|
number=201,
|
||||||
|
title="some feature",
|
||||||
|
state="open",
|
||||||
|
user=UserModel(login="michael"),
|
||||||
|
requested_reviewers=[]
|
||||||
|
)
|
||||||
|
mock_client.get_pull_request.return_value = pr_detail
|
||||||
|
|
||||||
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
|
work_item = WorkItem(
|
||||||
|
repo_full_name="meeks/repo1",
|
||||||
|
task_type="pr",
|
||||||
|
task_number=201,
|
||||||
|
task_info=PullRequestModel(number=201, title="some feature"),
|
||||||
|
priority=0
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
|
assert len(results) == 1
|
||||||
|
assert "SKIP: Agent is not a requested reviewer" in results[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_coordinator_tools_registration() -> None:
|
||||||
|
from core.coordinator_tools import CoordinatorTools
|
||||||
|
tools: CoordinatorTools = CoordinatorTools()
|
||||||
|
assert not tools.tool_called
|
||||||
|
assert tools.action == "NO_ACTION"
|
||||||
|
|
||||||
|
tools.propose_plan(plan="my plan", issue_number=42)
|
||||||
|
assert tools.tool_called
|
||||||
|
assert tools.action == "PROPOSE_PLAN"
|
||||||
|
assert tools.arguments == {"plan": "my plan", "issue_number": 42}
|
||||||
|
|
||||||
|
tools.start_implementation(approved_plan="my approved plan", issue_number=42)
|
||||||
|
assert tools.action == "EXECUTE_PLAN"
|
||||||
|
assert tools.arguments == {"approved_plan": "my approved plan", "issue_number": 42}
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.dispatcher.CoordinatorAgent")
|
||||||
|
async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMock) -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
mock_client.list_repo_pull_requests.return_value = []
|
||||||
|
mock_client.get_issue_comments.return_value = []
|
||||||
|
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||||
|
|
||||||
|
# Mock agent invoking propose_plan tool
|
||||||
|
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
|
||||||
|
coord_tools.propose_plan(plan="Step 1. Code X", issue_number=42)
|
||||||
|
return "Agent finished turn after tool calling."
|
||||||
|
|
||||||
|
mock_coord_instance = MagicMock()
|
||||||
|
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
|
||||||
|
mock_coord_class.return_value = mock_coord_instance
|
||||||
|
|
||||||
|
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
||||||
|
work_item = WorkItem(
|
||||||
|
repo_full_name="meeks/repo1",
|
||||||
|
task_type="issue",
|
||||||
|
task_number=42,
|
||||||
|
task_info=IssueModel(number=42, title="add X", body=""),
|
||||||
|
priority=0
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
|
assert len(results) == 1
|
||||||
|
assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0]
|
||||||
|
mock_client.add_comment.assert_called_once_with(
|
||||||
|
"meeks",
|
||||||
|
"repo1",
|
||||||
|
42,
|
||||||
|
"### Proposed Implementation Plan\n\nStep 1. Code X\n\nIs this plan ok for implementation or do you have any comments/changes?\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
from unittest.mock import MagicMock
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
from gitea.tools.file_tools import FileTools
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_file_content_string_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.get_file_content.return_value = "file content here"
|
||||||
|
|
||||||
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
|
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
||||||
|
assert res == "1: file content here"
|
||||||
|
mock_client.get_file_content.assert_called_once_with("owner", "repo", "path/to/file")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_file_content_list_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.get_file_content.return_value = ["line1", "line2"]
|
||||||
|
|
||||||
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
|
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
||||||
|
assert res == "1: line1\n2: line2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_file_content_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.get_file_content.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
|
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
||||||
|
assert "Error getting file content: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_file_content_with_ref_string_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.get_file_content.return_value = "file content here"
|
||||||
|
|
||||||
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
|
res: str = file_tools.get_file_content_with_ref("owner", "repo", "path/to/file", "main")
|
||||||
|
assert res == "1: file content here"
|
||||||
|
mock_client.get_file_content.assert_called_once_with("owner", "repo", "path/to/file", "main")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_file_content_with_ref_list_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.get_file_content.return_value = ["line1", "line2"]
|
||||||
|
|
||||||
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
|
res: str = file_tools.get_file_content_with_ref("owner", "repo", "path/to/file", "main")
|
||||||
|
assert res == "1: line1\n2: line2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_file_content_with_ref_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.get_file_content.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
|
res: str = file_tools.get_file_content_with_ref("owner", "repo", "path/to/file", "main")
|
||||||
|
assert "Error getting file content: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_file_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.update_file.return_value = {}
|
||||||
|
|
||||||
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
|
res: str = file_tools.commit_file("owner", "repo", "path/to/file", "msg", "content", "branch")
|
||||||
|
assert "committed successfully" in res
|
||||||
|
mock_client.update_file.assert_called_once_with("owner", "repo", "path/to/file", "msg", "content", "branch")
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_file_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.update_file.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
|
res: str = file_tools.commit_file("owner", "repo", "path/to/file", "msg", "content", "branch")
|
||||||
|
assert "Error committing file: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_file_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.update_file.return_value = {}
|
||||||
|
|
||||||
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
|
res: str = file_tools.update_file("owner", "repo", "path/to/file", "msg", "content", "branch")
|
||||||
|
assert "updated in" in res
|
||||||
|
mock_client.update_file.assert_called_once_with("owner", "repo", "path/to/file", "msg", "content", "branch")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_file_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.update_file.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
|
res: str = file_tools.update_file("owner", "repo", "path/to/file", "msg", "content", "branch")
|
||||||
|
assert "Error updating file: API Error" in res
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from unittest.mock import MagicMock
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
from gitea.tools.git_tools import GitTools
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_branch_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.create_ref.return_value = {}
|
||||||
|
|
||||||
|
git_tools: GitTools = GitTools(mock_client)
|
||||||
|
res: str = git_tools.create_branch("owner", "repo", "ref", "sha")
|
||||||
|
assert res == "Branch 'ref' created successfully in owner/repo."
|
||||||
|
mock_client.create_ref.assert_called_once_with("owner", "repo", "ref", "sha")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_branch_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.create_ref.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
git_tools: GitTools = GitTools(mock_client)
|
||||||
|
res: str = git_tools.create_branch("owner", "repo", "ref", "sha")
|
||||||
|
assert res == "Error creating branch: API Error"
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from unittest.mock import MagicMock
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
from gitea.tools.gitea_tools import GiteaTools
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_tools_delegation() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
gitea_tools: GiteaTools = GiteaTools(mock_client)
|
||||||
|
|
||||||
|
# 1. get_issue
|
||||||
|
gitea_tools.get_issue("owner", "repo", 1)
|
||||||
|
mock_client.get_issue.assert_called_once_with("owner", "repo", 1)
|
||||||
|
|
||||||
|
# 2. get_pull_request
|
||||||
|
gitea_tools.get_pull_request("owner", "repo", 2)
|
||||||
|
mock_client.get_pull_request.assert_called_once_with("owner", "repo", 2)
|
||||||
|
|
||||||
|
# 3. close_issue
|
||||||
|
gitea_tools.close_issue("owner", "repo", 3)
|
||||||
|
mock_client.close_issue.assert_called_once_with("owner", "repo", 3)
|
||||||
|
|
||||||
|
# 4. close_pull_request
|
||||||
|
gitea_tools.close_pull_request("owner", "repo", 4)
|
||||||
|
mock_client.close_pull_request.assert_called_once_with("owner", "repo", 4)
|
||||||
|
|
||||||
|
# 5. get_issue_comments
|
||||||
|
gitea_tools.get_issue_comments("owner", "repo", 5)
|
||||||
|
mock_client.get_issue_comments.assert_called_once_with("owner", "repo", 5)
|
||||||
|
|
||||||
|
# 6. get_pull_request_comments
|
||||||
|
gitea_tools.get_pull_request_comments("owner", "repo", 6)
|
||||||
|
mock_client.get_pull_request_comments.assert_called_once_with("owner", "repo", 6)
|
||||||
|
|
||||||
|
# 7. list_assigned_issues
|
||||||
|
mock_client.list_all_user_repos.return_value = []
|
||||||
|
gitea_tools.list_assigned_issues()
|
||||||
|
mock_client.list_all_user_repos.assert_called()
|
||||||
|
|
||||||
|
# 8. list_assigned_pull_requests
|
||||||
|
gitea_tools.list_assigned_pull_requests()
|
||||||
|
mock_client.list_all_user_repos.assert_called()
|
||||||
|
|
||||||
|
# 9. list_issues
|
||||||
|
gitea_tools.list_issues("owner", "repo")
|
||||||
|
mock_client.list_repo_issues.assert_called_once_with("owner", "repo", "open")
|
||||||
|
|
||||||
|
# 10. list_pull_requests
|
||||||
|
gitea_tools.list_pull_requests("owner", "repo")
|
||||||
|
mock_client.list_repo_pull_requests.assert_called_once_with("owner", "repo", "open")
|
||||||
|
|
||||||
|
# 11. get_file_content
|
||||||
|
gitea_tools.get_file_content("owner", "repo", "path")
|
||||||
|
mock_client.get_file_content.assert_called_with("owner", "repo", "path")
|
||||||
|
|
||||||
|
# 12. create_pull_request
|
||||||
|
gitea_tools.create_pull_request("owner", "repo", "head", "base", "title", "desc")
|
||||||
|
mock_client.create_pr_via_tea.assert_called_once_with("owner", "repo", "title", "desc", "head", "base")
|
||||||
|
|
||||||
|
# 13. create_issue
|
||||||
|
gitea_tools.create_issue("owner", "repo", "title", "body")
|
||||||
|
mock_client.create_issue.assert_called_once_with("owner", "repo", "title", "body", None, None)
|
||||||
|
|
||||||
|
# 14. create_branch
|
||||||
|
gitea_tools.create_branch("owner", "repo", "branch", "sha")
|
||||||
|
mock_client.create_ref.assert_called_once_with("owner", "repo", "branch", "sha")
|
||||||
|
|
||||||
|
# 15. commit_file
|
||||||
|
gitea_tools.commit_file("owner", "repo", "path", "msg", "content", "branch")
|
||||||
|
mock_client.update_file.assert_called_with("owner", "repo", "path", "msg", "content", "branch")
|
||||||
|
|
||||||
|
# 16. add_label_to_issue
|
||||||
|
gitea_tools.add_label_to_issue("owner", "repo", 1, "bug")
|
||||||
|
mock_client.add_label.assert_called_with("owner", "repo", 1, "bug")
|
||||||
|
|
||||||
|
# 17. add_label_to_pr
|
||||||
|
gitea_tools.add_label_to_pr("owner", "repo", 1, "bug")
|
||||||
|
mock_client.add_label_pr.assert_called_once_with("owner", "repo", 1, "bug")
|
||||||
|
|
||||||
|
# 18. add_comment_to_issue
|
||||||
|
gitea_tools.add_comment_to_issue("owner", "repo", 1, "body")
|
||||||
|
mock_client.add_comment.assert_called_with("owner", "repo", 1, "body")
|
||||||
|
|
||||||
|
# 19. get_pull_request_diff
|
||||||
|
gitea_tools.get_pull_request_diff("owner", "repo", 1)
|
||||||
|
mock_client.get_pull_request_diff.assert_called_once_with("owner", "repo", 1)
|
||||||
|
|
||||||
|
# 20. get_pull_request_patch
|
||||||
|
gitea_tools.get_pull_request_patch("owner", "repo", 1)
|
||||||
|
mock_client.get_pull_request_patch.assert_called_once_with("owner", "repo", 1)
|
||||||
|
|
||||||
|
# 21. approve_pull_request
|
||||||
|
gitea_tools.approve_pull_request("owner", "repo", 1, "good")
|
||||||
|
mock_client.approve_pr.assert_called_once_with("owner", "repo", 1, "good")
|
||||||
|
|
||||||
|
# 22. request_changes
|
||||||
|
gitea_tools.request_changes("owner", "repo", 1, "bad")
|
||||||
|
mock_client.request_changes_pr.assert_called_once_with("owner", "repo", 1, "bad")
|
||||||
|
|
||||||
|
# 23. add_comment
|
||||||
|
gitea_tools.add_comment("owner", "repo", 1, "body")
|
||||||
|
mock_client.add_comment.assert_called_with("owner", "repo", 1, "body")
|
||||||
|
|
||||||
|
# 24. add_label
|
||||||
|
gitea_tools.add_label("owner", "repo", 1, "bug")
|
||||||
|
mock_client.add_label.assert_called_with("owner", "repo", 1, "bug")
|
||||||
|
|
||||||
|
# 25. get_file_content_with_ref
|
||||||
|
gitea_tools.get_file_content_with_ref("owner", "repo", "path", "ref")
|
||||||
|
mock_client.get_file_content.assert_called_with("owner", "repo", "path", "ref")
|
||||||
|
|
||||||
|
# 26. update_file
|
||||||
|
gitea_tools.update_file("owner", "repo", "path", "msg", "content", "branch")
|
||||||
|
mock_client.update_file.assert_called_with("owner", "repo", "path", "msg", "content", "branch")
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
from gitea.models import IssueModel, CommentModel, LabelModel, RepositoryModel
|
||||||
|
from gitea.tools.issue_tools import IssueTools
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_issue_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
issue: IssueModel = IssueModel(number=1, title="Test Issue", state="open")
|
||||||
|
mock_client.get_issue.return_value = issue
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.get_issue("owner", "repo", 1)
|
||||||
|
|
||||||
|
data: dict[str, Any] = json.loads(res)
|
||||||
|
assert data["number"] == 1
|
||||||
|
assert data["title"] == "Test Issue"
|
||||||
|
mock_client.get_issue.assert_called_once_with("owner", "repo", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_issue_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.get_issue.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.get_issue("owner", "repo", 1)
|
||||||
|
assert "Error getting issue: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_issue_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.close_issue.return_value = IssueModel(number=1, state="closed")
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.close_issue("owner", "repo", 1)
|
||||||
|
assert res == "Issue #1 closed successfully."
|
||||||
|
mock_client.close_issue.assert_called_once_with("owner", "repo", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_issue_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.close_issue.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.close_issue("owner", "repo", 1)
|
||||||
|
assert "Error closing issue: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_issue_comments_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
comment: CommentModel = CommentModel(id=123, body="Comment body")
|
||||||
|
mock_client.get_issue_comments.return_value = [comment]
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.get_issue_comments("owner", "repo", 1)
|
||||||
|
data: list[dict[str, Any]] = json.loads(res)
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["body"] == "Comment body"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_issue_comments_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.get_issue_comments.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.get_issue_comments("owner", "repo", 1)
|
||||||
|
assert "Error getting issue comments: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_assigned_issues_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
repo: RepositoryModel = RepositoryModel(name="repo1", owner="owner1")
|
||||||
|
issue: IssueModel = IssueModel(number=1, title="Test Issue")
|
||||||
|
mock_client.list_all_user_repos.return_value = [repo]
|
||||||
|
mock_client.list_assigned_issues.return_value = [issue]
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: list[dict[str, Any]] = issue_tools.list_assigned_issues()
|
||||||
|
assert len(res) == 1
|
||||||
|
assert res[0]["number"] == 1
|
||||||
|
mock_client.list_all_user_repos.assert_called_once()
|
||||||
|
mock_client.list_assigned_issues.assert_called_once_with("owner1", "repo1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_assigned_issues_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.list_all_user_repos.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: list[dict[str, Any]] = issue_tools.list_assigned_issues()
|
||||||
|
assert res == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_issues_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
issue: IssueModel = IssueModel(number=1, title="Test Issue")
|
||||||
|
mock_client.list_repo_issues.return_value = [issue]
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.list_issues("owner", "repo")
|
||||||
|
assert "#1: Test Issue" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_issues_empty() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.list_repo_issues.return_value = []
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.list_issues("owner", "repo")
|
||||||
|
assert res == "No issues in owner/repo."
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_issues_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.list_repo_issues.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.list_issues("owner", "repo")
|
||||||
|
assert "Error listing issues: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_issue_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
issue: IssueModel = IssueModel(number=2)
|
||||||
|
mock_client.create_issue.return_value = issue
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.create_issue("owner", "repo", "Title", "Body", ["label1"], ["assignee1"])
|
||||||
|
assert res == "Issue #2 created successfully in owner/repo."
|
||||||
|
mock_client.create_issue.assert_called_once_with("owner", "repo", "Title", "Body", ["label1"], ["assignee1"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_issue_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.create_issue.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.create_issue("owner", "repo", "Title", "Body")
|
||||||
|
assert "Error creating issue: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_label_to_issue_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.add_label.return_value = LabelModel(name="bug")
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug")
|
||||||
|
assert res == "Label 'bug' added to issue #1."
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_label_to_issue_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.add_label.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug")
|
||||||
|
assert "Error adding label to issue #1: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_comment_to_issue_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.add_comment.return_value = CommentModel(id=1)
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.add_comment_to_issue("owner", "repo", 1, "body")
|
||||||
|
assert res == "Comment added to issue #1."
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_comment_to_issue_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.add_comment.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.add_comment_to_issue("owner", "repo", 1, "body")
|
||||||
|
assert "Error adding comment to issue #1: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_comment_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.add_comment.return_value = CommentModel(id=1)
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.add_comment("owner", "repo", 1, "body")
|
||||||
|
assert res == "Comment added to #1."
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_comment_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.add_comment.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.add_comment("owner", "repo", 1, "body")
|
||||||
|
assert "Error adding comment: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_label_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.add_label.return_value = LabelModel(name="bug")
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.add_label("owner", "repo", 1, "bug")
|
||||||
|
assert res == "Label 'bug' added to #1."
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_label_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.add_label.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
issue_tools: IssueTools = IssueTools(mock_client)
|
||||||
|
res: str = issue_tools.add_label("owner", "repo", 1, "bug")
|
||||||
|
assert "Error adding label: API Error" in res
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, AsyncMock, patch
|
||||||
|
from core.notification_tools import NotificationTools
|
||||||
|
from core.notification_agent import NotificationReaderAgent, NotificationNoToolCalledError
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.anyio
|
||||||
|
|
||||||
|
|
||||||
|
def test_notification_tools() -> None:
|
||||||
|
tools = NotificationTools()
|
||||||
|
assert tools.tool_called is False
|
||||||
|
assert tools.action == "NO_ACTION"
|
||||||
|
|
||||||
|
res = tools.process_issue("meeks", "repo", 42, "fix bug")
|
||||||
|
assert tools.tool_called is True
|
||||||
|
assert tools.action == "PROCESS_ISSUE"
|
||||||
|
assert tools.arguments == {
|
||||||
|
"owner": "meeks",
|
||||||
|
"repo": "repo",
|
||||||
|
"issue_number": 42,
|
||||||
|
"reason": "fix bug",
|
||||||
|
}
|
||||||
|
assert "marked for processing" in res
|
||||||
|
|
||||||
|
tools = NotificationTools()
|
||||||
|
res = tools.process_pr("meeks", "repo", 10, "review change")
|
||||||
|
assert tools.tool_called is True
|
||||||
|
assert tools.action == "PROCESS_PR"
|
||||||
|
assert tools.arguments == {
|
||||||
|
"owner": "meeks",
|
||||||
|
"repo": "repo",
|
||||||
|
"pr_number": 10,
|
||||||
|
"reason": "review change",
|
||||||
|
}
|
||||||
|
assert "marked for processing" in res
|
||||||
|
|
||||||
|
tools = NotificationTools()
|
||||||
|
res = tools.skip_notification("unrelated comments")
|
||||||
|
assert tools.tool_called is True
|
||||||
|
assert tools.action == "SKIP"
|
||||||
|
assert tools.arguments == {"reason": "unrelated comments"}
|
||||||
|
assert "marked to be skipped" in res
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.notification_agent.NotificationReaderAgent.initialize")
|
||||||
|
@patch("core.notification_agent.NotificationReaderAgent.run_with_tools")
|
||||||
|
async def test_notification_reader_agent_success(
|
||||||
|
mock_run_with_tools: MagicMock,
|
||||||
|
mock_initialize: MagicMock
|
||||||
|
) -> None:
|
||||||
|
mock_initialize.return_value = None
|
||||||
|
agent = NotificationReaderAgent("dummy-model")
|
||||||
|
|
||||||
|
# Mock tool call inside run_with_tools
|
||||||
|
async def mock_run(mission: str, tools: list) -> str:
|
||||||
|
# Simulate calling a tool
|
||||||
|
for t in tools:
|
||||||
|
if getattr(t, "__name__", "") == "process_issue":
|
||||||
|
t("meeks", "repo", 42, "reason")
|
||||||
|
return "response"
|
||||||
|
|
||||||
|
mock_run_with_tools.side_effect = mock_run
|
||||||
|
|
||||||
|
tools = NotificationTools()
|
||||||
|
res = await agent.decide_notification("mission", [], tools)
|
||||||
|
assert res == "response"
|
||||||
|
assert tools.tool_called is True
|
||||||
|
assert tools.action == "PROCESS_ISSUE"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.notification_agent.NotificationReaderAgent.initialize")
|
||||||
|
@patch("core.notification_agent.NotificationReaderAgent.run_with_tools")
|
||||||
|
async def test_notification_reader_agent_no_tool_error(
|
||||||
|
mock_run_with_tools: MagicMock,
|
||||||
|
mock_initialize: MagicMock
|
||||||
|
) -> None:
|
||||||
|
mock_initialize.return_value = None
|
||||||
|
agent = NotificationReaderAgent("dummy-model")
|
||||||
|
mock_run_with_tools.return_value = "no tool called"
|
||||||
|
|
||||||
|
tools = NotificationTools()
|
||||||
|
with pytest.raises(NotificationNoToolCalledError):
|
||||||
|
await agent.decide_notification("mission", [], tools)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, AsyncMock, patch
|
||||||
|
|
||||||
|
from core.orchestrator import AgentOrchestrator
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
from gitea.tools.gitea_tools import GiteaTools
|
||||||
|
from gitea.models import IssueModel, PullRequestModel, RepositoryModel
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.anyio
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_state_file(tmp_path: Path) -> Path:
|
||||||
|
"""Fixture to mock state file path."""
|
||||||
|
state_file = tmp_path / "agent_state.json"
|
||||||
|
return state_file
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.orchestrator.AgentOrchestrator._get_state_file_path")
|
||||||
|
@patch("core.orchestrator.AgentDispatcher")
|
||||||
|
@patch("core.orchestrator.WorkspaceManager")
|
||||||
|
@patch("core.orchestrator.AgentFactory")
|
||||||
|
async def test_poll_and_dispatch_no_notifications(
|
||||||
|
mock_factory: MagicMock,
|
||||||
|
mock_workspace_class: MagicMock,
|
||||||
|
mock_dispatcher_class: MagicMock,
|
||||||
|
mock_get_path: MagicMock,
|
||||||
|
temp_state_file: Path
|
||||||
|
) -> None:
|
||||||
|
mock_get_path.return_value = temp_state_file
|
||||||
|
mock_client = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
# Return no notifications
|
||||||
|
mock_client.list_unread_notifications.return_value = []
|
||||||
|
|
||||||
|
orchestrator = AgentOrchestrator(mock_client, mock_tools)
|
||||||
|
await orchestrator.poll_and_dispatch()
|
||||||
|
|
||||||
|
mock_client.list_unread_notifications.assert_called_once_with(since=None)
|
||||||
|
assert not temp_state_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.orchestrator.AgentOrchestrator._get_state_file_path")
|
||||||
|
@patch("core.orchestrator.AgentDispatcher")
|
||||||
|
@patch("core.orchestrator.WorkspaceManager")
|
||||||
|
@patch("core.orchestrator.AgentFactory")
|
||||||
|
async def test_poll_and_dispatch_with_notifications(
|
||||||
|
mock_factory: MagicMock,
|
||||||
|
mock_workspace_class: MagicMock,
|
||||||
|
mock_dispatcher_class: MagicMock,
|
||||||
|
mock_get_path: MagicMock,
|
||||||
|
temp_state_file: Path
|
||||||
|
) -> None:
|
||||||
|
mock_get_path.return_value = temp_state_file
|
||||||
|
|
||||||
|
mock_reader = MagicMock()
|
||||||
|
async def mock_decide_notification(mission: str, inspection_tools: list, notification_tools) -> str:
|
||||||
|
if "issue" in mission or "42" in mission:
|
||||||
|
notification_tools.process_issue("meeks", "repo1", 42, "Needs processing")
|
||||||
|
elif "pull" in mission or "10" in mission:
|
||||||
|
notification_tools.process_pr("meeks", "repo1", 10, "Needs processing")
|
||||||
|
else:
|
||||||
|
notification_tools.skip_notification("Unrelated")
|
||||||
|
return "Decided"
|
||||||
|
mock_reader.decide_notification = AsyncMock(side_effect=mock_decide_notification)
|
||||||
|
mock_factory.create_notification_reader_agent.return_value = mock_reader
|
||||||
|
mock_client = MagicMock(spec=GiteaClient)
|
||||||
|
mock_tools = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
# Set up mock Gitea notifications
|
||||||
|
notifications = [
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"updated_at": "2026-06-30T10:00:00Z",
|
||||||
|
"subject": {
|
||||||
|
"type": "issue",
|
||||||
|
"url": "https://gitea.meeks.freeddns.org/api/v1/repos/meeks/repo1/issues/42"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"name": "repo1",
|
||||||
|
"full_name": "meeks/repo1",
|
||||||
|
"owner": {"login": "meeks"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 102,
|
||||||
|
"updated_at": "2026-06-30T11:00:00Z",
|
||||||
|
"subject": {
|
||||||
|
"type": "pull",
|
||||||
|
"url": "https://gitea.meeks.freeddns.org/api/v1/repos/meeks/repo1/pulls/10"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"name": "repo1",
|
||||||
|
"full_name": "meeks/repo1",
|
||||||
|
"owner": {"login": "meeks"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_client.list_unread_notifications.return_value = notifications
|
||||||
|
|
||||||
|
# Mock issue and PR get methods
|
||||||
|
issue_model = IssueModel(number=42, title="Bug issue", repository=RepositoryModel(name="repo1", full_name="meeks/repo1"))
|
||||||
|
pr_model = PullRequestModel(number=10, title="Fix PR", repository=RepositoryModel(name="repo1", full_name="meeks/repo1"))
|
||||||
|
mock_client.get_issue.return_value = issue_model
|
||||||
|
mock_client.get_pull_request.return_value = pr_model
|
||||||
|
|
||||||
|
# Mock dispatcher and workspace path
|
||||||
|
mock_dispatcher_instance = MagicMock()
|
||||||
|
mock_dispatcher_instance.dispatch = AsyncMock(return_value=["Issue comment posted", "PR verified"])
|
||||||
|
mock_dispatcher_class.return_value = mock_dispatcher_instance
|
||||||
|
|
||||||
|
mock_workspace_instance = MagicMock()
|
||||||
|
mock_workspace_instance.get_repo_path.return_value.exists.return_value = True
|
||||||
|
mock_workspace_class.return_value = mock_workspace_instance
|
||||||
|
|
||||||
|
# Create orchestrator and poll
|
||||||
|
orchestrator = AgentOrchestrator(mock_client, mock_tools)
|
||||||
|
await orchestrator.poll_and_dispatch()
|
||||||
|
|
||||||
|
# Assert notifications were checked with None (first execution)
|
||||||
|
mock_client.list_unread_notifications.assert_called_once_with(since=None)
|
||||||
|
|
||||||
|
# Assert issue and PR details were fetched
|
||||||
|
mock_client.get_issue.assert_called_once_with("meeks", "repo1", 42)
|
||||||
|
mock_client.get_pull_request.assert_called_once_with("meeks", "repo1", 10)
|
||||||
|
|
||||||
|
# Assert work was processed by dispatcher
|
||||||
|
mock_dispatcher_instance.dispatch.assert_called_once()
|
||||||
|
work_items = mock_dispatcher_instance.dispatch.call_args[0][1]
|
||||||
|
assert len(work_items) == 2
|
||||||
|
assert work_items[0].task_number == 42
|
||||||
|
assert work_items[0].notification_id == 101
|
||||||
|
assert work_items[1].task_number == 10
|
||||||
|
assert work_items[1].notification_id == 102
|
||||||
|
|
||||||
|
# Assert notifications were marked as read
|
||||||
|
mock_client.mark_notification_as_read.assert_any_call(101)
|
||||||
|
mock_client.mark_notification_as_read.assert_any_call(102)
|
||||||
|
assert mock_client.mark_notification_as_read.call_count == 2
|
||||||
|
|
||||||
|
# Assert checkpoint date was persisted
|
||||||
|
assert temp_state_file.exists()
|
||||||
|
with open(temp_state_file, "r") as f:
|
||||||
|
state = json.load(f)
|
||||||
|
# Checkpoint should match latest updated_at
|
||||||
|
assert state["last_checked"] == "2026-06-30T11:00:00Z"
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from gitea.client import GiteaClient
|
||||||
|
from gitea.models import PullRequestModel, CommentModel, RepositoryModel
|
||||||
|
from gitea.tools.pr_tools import PRTools
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_pull_request_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
pr: PullRequestModel = PullRequestModel(number=1, title="Test PR", state="open")
|
||||||
|
mock_client.get_pull_request.return_value = pr
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.get_pull_request("owner", "repo", 1)
|
||||||
|
|
||||||
|
data: dict[str, Any] = json.loads(res)
|
||||||
|
assert data["number"] == 1
|
||||||
|
assert data["title"] == "Test PR"
|
||||||
|
mock_client.get_pull_request.assert_called_once_with("owner", "repo", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_pull_request_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.get_pull_request.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.get_pull_request("owner", "repo", 1)
|
||||||
|
assert "Error getting pull request: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_pull_request_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.close_pull_request.return_value = PullRequestModel(number=1, state="closed")
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.close_pull_request("owner", "repo", 1)
|
||||||
|
assert res == "Pull request #1 closed successfully."
|
||||||
|
mock_client.close_pull_request.assert_called_once_with("owner", "repo", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_pull_request_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.close_pull_request.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.close_pull_request("owner", "repo", 1)
|
||||||
|
assert "Error closing pull request: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_pull_request_comments_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
comment: CommentModel = CommentModel(id=123, body="Comment body")
|
||||||
|
mock_client.get_pull_request_comments.return_value = [comment]
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.get_pull_request_comments("owner", "repo", 1)
|
||||||
|
data: list[dict[str, Any]] = json.loads(res)
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["body"] == "Comment body"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_pull_request_comments_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.get_pull_request_comments.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.get_pull_request_comments("owner", "repo", 1)
|
||||||
|
assert "Error getting PR comments: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_assigned_pull_requests_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
repo: RepositoryModel = RepositoryModel(name="repo1", owner="owner1")
|
||||||
|
pr: PullRequestModel = PullRequestModel(number=1, title="Test PR")
|
||||||
|
mock_client.list_all_user_repos.return_value = [repo]
|
||||||
|
mock_client.list_assigned_pull_requests.return_value = [pr]
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: list[dict[str, Any]] = pr_tools.list_assigned_pull_requests()
|
||||||
|
assert len(res) == 1
|
||||||
|
assert res[0]["number"] == 1
|
||||||
|
mock_client.list_all_user_repos.assert_called_once()
|
||||||
|
mock_client.list_assigned_pull_requests.assert_called_once_with("owner1", "repo1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_assigned_pull_requests_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.list_all_user_repos.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: list[dict[str, Any]] = pr_tools.list_assigned_pull_requests()
|
||||||
|
assert res == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_pull_requests_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
pr: PullRequestModel = PullRequestModel(number=1, title="Test PR")
|
||||||
|
mock_client.list_repo_pull_requests.return_value = [pr]
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.list_pull_requests("owner", "repo")
|
||||||
|
assert "#1: Test PR" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_pull_requests_empty() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.list_repo_pull_requests.return_value = []
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.list_pull_requests("owner", "repo")
|
||||||
|
assert res == "No PRs in owner/repo."
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_pull_requests_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.list_repo_pull_requests.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.list_pull_requests("owner", "repo")
|
||||||
|
assert "Error listing PRs: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_pull_request_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
pr: PullRequestModel = PullRequestModel(number=2, title="Title")
|
||||||
|
mock_client.create_pr_via_tea.return_value = pr
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.create_pull_request("owner", "repo", "head", "base", "Title", "Desc")
|
||||||
|
data: dict[str, Any] = json.loads(res)
|
||||||
|
assert data["number"] == 2
|
||||||
|
mock_client.create_pr_via_tea.assert_called_once_with("owner", "repo", "Title", "Desc", "head", "base")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_pull_request_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.create_pr_via_tea.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.create_pull_request("owner", "repo", "head", "base", "Title")
|
||||||
|
assert "Error creating PR: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_label_to_pr_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.add_label_pr.return_value = {}
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug")
|
||||||
|
assert res == "Label 'bug' added to PR #1."
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_label_to_pr_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.add_label_pr.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug")
|
||||||
|
assert "Error adding label to PR #1: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_pull_request_diff_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.get_pull_request_diff.return_value = "diff content"
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.get_pull_request_diff("owner", "repo", 1)
|
||||||
|
assert res == "diff content"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_pull_request_diff_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.get_pull_request_diff.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.get_pull_request_diff("owner", "repo", 1)
|
||||||
|
assert "Error getting PR diff: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_pull_request_patch_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.get_pull_request_patch.return_value = "patch content"
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.get_pull_request_patch("owner", "repo", 1)
|
||||||
|
assert res == "patch content"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_pull_request_patch_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.get_pull_request_patch.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.get_pull_request_patch("owner", "repo", 1)
|
||||||
|
assert "Error getting PR patch: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_pull_request_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.approve_pr.return_value = {}
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good")
|
||||||
|
assert res == "Approved PR #1."
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_pull_request_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.approve_pr.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good")
|
||||||
|
assert "Error approving PR: API Error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_changes_success() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.request_changes_pr.return_value = {}
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.request_changes("owner", "repo", 1, "bad")
|
||||||
|
assert res == "Requested changes on PR #1."
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_changes_failure() -> None:
|
||||||
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
|
mock_client.request_changes_pr.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
pr_tools: PRTools = PRTools(mock_client)
|
||||||
|
res: str = pr_tools.request_changes("owner", "repo", 1, "bad")
|
||||||
|
assert "Error requesting changes: API Error" in res
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
"""Tests for ResearchTools: web_search and fetch_url."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
from unittest.mock import MagicMock, patch, PropertyMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from gitea.tools.research_tools import ResearchTools, _MAX_CONTENT_CHARS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tools() -> ResearchTools:
|
||||||
|
return ResearchTools()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# _smart_truncate
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
class TestSmartTruncate:
|
||||||
|
def test_no_truncation_if_short(self, tools: ResearchTools) -> None:
|
||||||
|
text = "hello world"
|
||||||
|
assert tools._smart_truncate(text, max_chars=100) == text
|
||||||
|
|
||||||
|
def test_cuts_at_paragraph_boundary(self, tools: ResearchTools) -> None:
|
||||||
|
# Two paragraphs; the boundary falls after the 70% mark of max_chars
|
||||||
|
para1 = "A" * 80
|
||||||
|
para2 = "B" * 80
|
||||||
|
text = para1 + "\n\n" + para2
|
||||||
|
result = tools._smart_truncate(text, max_chars=100)
|
||||||
|
# Should cut at the \n\n, not mid-word
|
||||||
|
assert "truncated" in result
|
||||||
|
assert result.startswith(para1)
|
||||||
|
|
||||||
|
def test_hard_cut_when_no_good_boundary(self, tools: ResearchTools) -> None:
|
||||||
|
# Single block — no paragraph boundary available
|
||||||
|
text = "x" * 200
|
||||||
|
result = tools._smart_truncate(text, max_chars=100)
|
||||||
|
assert "truncated" in result
|
||||||
|
assert result.startswith("x" * 100)
|
||||||
|
|
||||||
|
def test_exact_length_not_truncated(self, tools: ResearchTools) -> None:
|
||||||
|
text = "a" * 100
|
||||||
|
assert tools._smart_truncate(text, max_chars=100) == text
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# _html_to_markdown — regex fallback (no optional deps needed)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
class TestHtmlToMarkdown:
|
||||||
|
def _patch_imports(self, tools: ResearchTools) -> str:
|
||||||
|
"""Return result when optional deps are unavailable."""
|
||||||
|
# Force all optional imports to fail → regex fallback
|
||||||
|
import builtins
|
||||||
|
real_import = builtins.__import__
|
||||||
|
|
||||||
|
def mock_import(name: str, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||||
|
if name in ("trafilatura", "readability", "markdownify"):
|
||||||
|
raise ImportError(f"mocked missing: {name}")
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
with patch("builtins.__import__", side_effect=mock_import):
|
||||||
|
return tools._html_to_markdown("<p>Hello <b>world</b></p>")
|
||||||
|
|
||||||
|
def test_regex_fallback_removes_tags(self, tools: ResearchTools) -> None:
|
||||||
|
result = self._patch_imports(tools)
|
||||||
|
assert "Hello" in result
|
||||||
|
assert "world" in result
|
||||||
|
assert "<p>" not in result
|
||||||
|
assert "<b>" not in result
|
||||||
|
|
||||||
|
def test_regex_fallback_removes_script(self, tools: ResearchTools) -> None:
|
||||||
|
import builtins
|
||||||
|
real_import = builtins.__import__
|
||||||
|
|
||||||
|
def mock_import(name: str, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||||
|
if name in ("trafilatura", "readability", "markdownify"):
|
||||||
|
raise ImportError
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
with patch("builtins.__import__", side_effect=mock_import):
|
||||||
|
result = tools._html_to_markdown("<script>evil()</script>visible")
|
||||||
|
assert "evil" not in result
|
||||||
|
assert "visible" in result
|
||||||
|
|
||||||
|
def test_regex_fallback_decodes_entities(self, tools: ResearchTools) -> None:
|
||||||
|
import builtins
|
||||||
|
real_import = builtins.__import__
|
||||||
|
|
||||||
|
def mock_import(name: str, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||||
|
if name in ("trafilatura", "readability", "markdownify"):
|
||||||
|
raise ImportError
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
with patch("builtins.__import__", side_effect=mock_import):
|
||||||
|
result = tools._html_to_markdown("Tom & Jerry <3>")
|
||||||
|
assert "Tom & Jerry <3>" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# _format_results
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatResults:
|
||||||
|
def test_numbered_list(self, tools: ResearchTools) -> None:
|
||||||
|
results = [
|
||||||
|
{"title": "Title A", "href": "https://a.com", "body": "Snippet A"},
|
||||||
|
{"title": "Title B", "href": "https://b.com", "body": "Snippet B"},
|
||||||
|
]
|
||||||
|
output = tools._format_results(results, "test query")
|
||||||
|
assert "[1]" in output
|
||||||
|
assert "[2]" in output
|
||||||
|
assert "https://a.com" in output
|
||||||
|
assert "Snippet A" in output
|
||||||
|
|
||||||
|
def test_includes_date_when_present(self, tools: ResearchTools) -> None:
|
||||||
|
results = [{"title": "T", "href": "https://x.com", "body": "S", "published_date": "2024-01"}]
|
||||||
|
output = tools._format_results(results, "q")
|
||||||
|
assert "Date: 2024-01" in output
|
||||||
|
|
||||||
|
def test_no_date_field_when_absent(self, tools: ResearchTools) -> None:
|
||||||
|
results = [{"title": "T", "href": "https://x.com", "body": "S"}]
|
||||||
|
output = tools._format_results(results, "q")
|
||||||
|
assert "Date:" not in output
|
||||||
|
|
||||||
|
def test_snippet_truncated_to_250_chars(self, tools: ResearchTools) -> None:
|
||||||
|
long_body = "x" * 500
|
||||||
|
results = [{"title": "T", "href": "u", "body": long_body}]
|
||||||
|
output = tools._format_results(results, "q")
|
||||||
|
assert "x" * 251 not in output # body was truncated before formatting
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# _search_searxng
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchSearxng:
|
||||||
|
def _make_searxng_response(self, results: list[dict]) -> MagicMock:
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.json.return_value = {"results": results}
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
return mock_resp
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.httpx.Client")
|
||||||
|
def test_returns_formatted_results_on_success(
|
||||||
|
self, mock_cls: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
results = [
|
||||||
|
{"title": "SearXNG Result", "url": "https://example.com", "content": "snippet"},
|
||||||
|
]
|
||||||
|
mock_cls.return_value.__enter__.return_value.get.return_value = (
|
||||||
|
self._make_searxng_response(results)
|
||||||
|
)
|
||||||
|
output = tools._search_searxng("test query", num_results=5)
|
||||||
|
assert output is not None
|
||||||
|
assert "[1]" in output
|
||||||
|
assert "example.com" in output
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.httpx.Client")
|
||||||
|
def test_returns_none_when_empty_results(
|
||||||
|
self, mock_cls: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
mock_cls.return_value.__enter__.return_value.get.return_value = (
|
||||||
|
self._make_searxng_response([])
|
||||||
|
)
|
||||||
|
assert tools._search_searxng("nothing", num_results=5) is None
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.httpx.Client")
|
||||||
|
def test_returns_none_on_connection_error(
|
||||||
|
self, mock_cls: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
mock_cls.return_value.__enter__.return_value.get.side_effect = (
|
||||||
|
httpx.ConnectError("refused")
|
||||||
|
)
|
||||||
|
assert tools._search_searxng("query", num_results=5) is None
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.httpx.Client")
|
||||||
|
def test_respects_time_range_param(
|
||||||
|
self, mock_cls: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
mock_cls.return_value.__enter__.return_value.get.return_value = (
|
||||||
|
self._make_searxng_response([])
|
||||||
|
)
|
||||||
|
tools._search_searxng("q", num_results=5, time_range="month")
|
||||||
|
call_kwargs = mock_cls.return_value.__enter__.return_value.get.call_args
|
||||||
|
params = call_kwargs[1].get("params", call_kwargs[0][1] if len(call_kwargs[0]) > 1 else {})
|
||||||
|
assert params.get("time_range") == "month"
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.httpx.Client")
|
||||||
|
def test_passes_basic_auth_if_configured(
|
||||||
|
self, mock_cls: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
mock_cls.return_value.__enter__.return_value.get.return_value = (
|
||||||
|
self._make_searxng_response([])
|
||||||
|
)
|
||||||
|
with patch("gitea.tools.research_tools._SEARXNG_USERNAME", "user"), \
|
||||||
|
patch("gitea.tools.research_tools._SEARXNG_PASSWORD", "pass"):
|
||||||
|
tools._search_searxng("q", num_results=5)
|
||||||
|
|
||||||
|
mock_cls.assert_called_once()
|
||||||
|
kwargs = mock_cls.call_args[1]
|
||||||
|
assert kwargs.get("auth") == ("user", "pass")
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.httpx.Client")
|
||||||
|
def test_web_search_uses_searxng_first(
|
||||||
|
self, mock_cls: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
"""web_search should return SearXNG results without touching DDGS."""
|
||||||
|
results = [{"title": "From SearXNG", "url": "https://sx.com", "content": "content"}]
|
||||||
|
mock_cls.return_value.__enter__.return_value.get.return_value = (
|
||||||
|
self._make_searxng_response(results)
|
||||||
|
)
|
||||||
|
output = tools.web_search("python typing")
|
||||||
|
assert "From SearXNG" in output or "[1]" in output
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# web_search — with DDGS mocked
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSearch:
|
||||||
|
def _make_ddgs_result(self) -> list[dict[str, str]]:
|
||||||
|
return [
|
||||||
|
{"title": "Foo Docs", "href": "https://foo.com/docs", "body": "Learn about Foo."},
|
||||||
|
{"title": "Bar Guide", "href": "https://bar.com", "body": "A guide to Bar."},
|
||||||
|
]
|
||||||
|
|
||||||
|
def _mock_ddgs(self, results: list[dict[str, str]]) -> MagicMock:
|
||||||
|
mock_ddgs_instance = MagicMock()
|
||||||
|
mock_ddgs_instance.__enter__ = MagicMock(return_value=mock_ddgs_instance)
|
||||||
|
mock_ddgs_instance.__exit__ = MagicMock(return_value=False)
|
||||||
|
mock_ddgs_instance.text.return_value = iter(results)
|
||||||
|
return mock_ddgs_instance
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.time.sleep")
|
||||||
|
def test_returns_numbered_results(
|
||||||
|
self, _mock_sleep: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
mock_cls = MagicMock(return_value=self._mock_ddgs(self._make_ddgs_result()))
|
||||||
|
with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \
|
||||||
|
patch("gitea.tools.research_tools.DDGS", mock_cls):
|
||||||
|
result = tools.web_search("python httpx")
|
||||||
|
assert "[1]" in result
|
||||||
|
assert "foo.com" in result
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.time.sleep")
|
||||||
|
def test_no_results_returns_helpful_message(
|
||||||
|
self, _mock_sleep: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
mock_cls = MagicMock(return_value=self._mock_ddgs([]))
|
||||||
|
with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \
|
||||||
|
patch("gitea.tools.research_tools.DDGS", mock_cls):
|
||||||
|
result = tools.web_search("xyzzy-not-real")
|
||||||
|
assert "No results" in result
|
||||||
|
|
||||||
|
def test_clamps_num_results_max(self, tools: ResearchTools) -> None:
|
||||||
|
ddgs_mock = self._mock_ddgs(self._make_ddgs_result())
|
||||||
|
mock_cls = MagicMock(return_value=ddgs_mock)
|
||||||
|
with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \
|
||||||
|
patch("gitea.tools.research_tools.DDGS", mock_cls):
|
||||||
|
tools.web_search("q", num_results=999)
|
||||||
|
# DDGS.text should be called with max_results clamped to 20
|
||||||
|
ddgs_mock.text.assert_called_once()
|
||||||
|
_, kwargs = ddgs_mock.text.call_args
|
||||||
|
assert kwargs.get("max_results", 0) <= 20
|
||||||
|
|
||||||
|
def test_clamps_num_results_min(self, tools: ResearchTools) -> None:
|
||||||
|
ddgs_mock = self._mock_ddgs(self._make_ddgs_result())
|
||||||
|
mock_cls = MagicMock(return_value=ddgs_mock)
|
||||||
|
with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \
|
||||||
|
patch("gitea.tools.research_tools.DDGS", mock_cls):
|
||||||
|
tools.web_search("q", num_results=0)
|
||||||
|
_, kwargs = ddgs_mock.text.call_args
|
||||||
|
assert kwargs.get("max_results", 0) >= 1
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.time.sleep")
|
||||||
|
def test_retries_on_rate_limit(
|
||||||
|
self, mock_sleep: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
"""Should retry up to 3 times with exponential backoff on RatelimitException."""
|
||||||
|
rate_exc = Exception("rate limit")
|
||||||
|
ddgs_mock = MagicMock()
|
||||||
|
ddgs_mock.__enter__ = MagicMock(return_value=ddgs_mock)
|
||||||
|
ddgs_mock.__exit__ = MagicMock(return_value=False)
|
||||||
|
ddgs_mock.text.side_effect = rate_exc
|
||||||
|
|
||||||
|
with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \
|
||||||
|
patch("gitea.tools.research_tools.DDGS", return_value=ddgs_mock), \
|
||||||
|
patch("gitea.tools.research_tools.RatelimitException", type(rate_exc)), \
|
||||||
|
patch("gitea.tools.research_tools.DuckDuckGoSearchException", ValueError):
|
||||||
|
result = tools.web_search("q")
|
||||||
|
|
||||||
|
assert isinstance(result, str) # Returns error string, not raise
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# fetch_url
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchUrl:
|
||||||
|
def _make_response(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
content_type: str = "text/html; charset=utf-8",
|
||||||
|
status_code: int = 200,
|
||||||
|
) -> MagicMock:
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.text = text
|
||||||
|
mock_resp.headers = {"content-type": content_type}
|
||||||
|
mock_resp.status_code = status_code
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
return mock_resp
|
||||||
|
|
||||||
|
def test_rejects_non_http_url(self, tools: ResearchTools) -> None:
|
||||||
|
result = tools.fetch_url("ftp://example.com/file")
|
||||||
|
assert "Invalid URL" in result
|
||||||
|
|
||||||
|
def test_rejects_no_scheme(self, tools: ResearchTools) -> None:
|
||||||
|
result = tools.fetch_url("example.com")
|
||||||
|
assert "Invalid URL" in result
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.httpx.Client")
|
||||||
|
def test_pretty_prints_json_response(
|
||||||
|
self, mock_cls: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
data = {"key": "value", "n": 42}
|
||||||
|
mock_resp = self._make_response(
|
||||||
|
json.dumps(data), content_type="application/json"
|
||||||
|
)
|
||||||
|
mock_resp.json = MagicMock(return_value=data)
|
||||||
|
mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||||
|
|
||||||
|
result = tools.fetch_url("https://api.example.com/data")
|
||||||
|
assert '"key": "value"' in result
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.httpx.Client")
|
||||||
|
def test_returns_plain_text_as_is(
|
||||||
|
self, mock_cls: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
mock_resp = self._make_response("plain text content", content_type="text/plain")
|
||||||
|
mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||||
|
|
||||||
|
result = tools.fetch_url("https://example.com/readme.txt")
|
||||||
|
assert "plain text content" in result
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.httpx.Client")
|
||||||
|
def test_returns_markdown_file_as_is(
|
||||||
|
self, mock_cls: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
mock_resp = self._make_response("# Heading\nContent", content_type="text/plain")
|
||||||
|
mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||||
|
|
||||||
|
result = tools.fetch_url("https://example.com/README.md")
|
||||||
|
assert "# Heading" in result
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.httpx.Client")
|
||||||
|
def test_raw_html_when_extract_false(
|
||||||
|
self, mock_cls: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
html = "<html><body><p>Content</p></body></html>"
|
||||||
|
mock_resp = self._make_response(html)
|
||||||
|
mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||||
|
|
||||||
|
result = tools.fetch_url("https://example.com", extract_text=False)
|
||||||
|
assert "<p>" in result
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.httpx.Client")
|
||||||
|
def test_truncates_at_max_chars(
|
||||||
|
self, mock_cls: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
mock_resp = self._make_response("word " * 10000, content_type="text/plain")
|
||||||
|
mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||||
|
|
||||||
|
result = tools.fetch_url("https://example.com/big", max_chars=100)
|
||||||
|
assert "truncated" in result
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.httpx.Client")
|
||||||
|
def test_handles_http_404(
|
||||||
|
self, mock_cls: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
import httpx as _httpx
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 404
|
||||||
|
mock_cls.return_value.__enter__.return_value.get.side_effect = (
|
||||||
|
_httpx.HTTPStatusError("not found", request=MagicMock(), response=mock_response)
|
||||||
|
)
|
||||||
|
result = tools.fetch_url("https://example.com/missing")
|
||||||
|
assert "404" in result or "Failed" in result
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.httpx.Client")
|
||||||
|
def test_handles_timeout(
|
||||||
|
self, mock_cls: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
import httpx as _httpx
|
||||||
|
|
||||||
|
mock_cls.return_value.__enter__.return_value.get.side_effect = (
|
||||||
|
_httpx.TimeoutException("timed out")
|
||||||
|
)
|
||||||
|
result = tools.fetch_url("https://slow.example.com")
|
||||||
|
assert "timed out" in result.lower() or "timeout" in result.lower()
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.httpx.Client")
|
||||||
|
def test_html_extraction_called_for_html_content(
|
||||||
|
self, mock_cls: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
html = "<html><body><p>Hello world from main content</p></body></html>"
|
||||||
|
mock_resp = self._make_response(html)
|
||||||
|
mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||||
|
|
||||||
|
with patch.object(tools, "_html_to_markdown", return_value="Hello world") as mock_extract:
|
||||||
|
result = tools.fetch_url("https://example.com")
|
||||||
|
mock_extract.assert_called_once_with(html)
|
||||||
|
assert "Hello world" in result
|
||||||
|
|
||||||
|
@patch("gitea.tools.research_tools.httpx.Client")
|
||||||
|
def test_default_max_chars_is_20k(
|
||||||
|
self, mock_cls: MagicMock, tools: ResearchTools
|
||||||
|
) -> None:
|
||||||
|
mock_resp = self._make_response("a" * 30000, content_type="text/plain")
|
||||||
|
mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||||
|
|
||||||
|
result = tools.fetch_url("https://example.com/long")
|
||||||
|
assert "truncated" in result
|
||||||
|
# Content before truncation note should be ~20k chars
|
||||||
|
content_before = result.split("[Content truncated")[0]
|
||||||
|
# Allow a small buffer for the trailing \n\n appended before the truncation note
|
||||||
|
assert len(content_before) <= _MAX_CONTENT_CHARS + 4
|
||||||
Reference in New Issue
Block a user