Refactor code structure for improved readability and maintainability
This commit is contained in:
+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
|
||||
Reference in New Issue
Block a user