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