fix: improve prompts, error messages, and workspace concurrency
- Externalize coordinator, notification, and planning prompts to separate files - Add workspace mutex for concurrent file operations - Improve error messages across file_tools, issue_tools, and pr_tools - Add logging to tool modules for better debugging - Update tests to match new error message strings
This commit is contained in:
+9
-58
@@ -1,64 +1,15 @@
|
||||
"""System prompts and configurations for agents."""
|
||||
"""System prompts for agents, loaded from external prompt files."""
|
||||
|
||||
COORDINATOR_SYSTEM_PROMPT: str = """
|
||||
You are an AI Coordinator. Your job is to analyze Gitea issues, read the conversation history, and determine the next action for the agent.
|
||||
from pathlib import Path
|
||||
|
||||
Based on the conversation state, you must choose and call exactly one of the following tools:
|
||||
|
||||
1. `propose_plan`: Choose this if code changes are needed to resolve the issue, and either:
|
||||
- No plan has been proposed yet by the AI agent.
|
||||
- Or a plan was proposed, but the human replied with feedback, corrections, or requests for changes, so we need to propose a revised plan.
|
||||
You must provide a detailed, step-by-step implementation plan (listing files to modify/create, specific changes to make, verification/test commands).
|
||||
|
||||
2. `start_implementation`: Choose this if:
|
||||
- A plan was previously proposed AND the human has clearly replied with approval/greenlight/go-ahead (e.g., "yes", "looks good", "ok", "go ahead", etc.).
|
||||
- Or there is an existing WIP PR or a PR with requested changes, and we need to resume implementing the changes.
|
||||
You must extract/summarize the approved plan, incorporating any human feedback.
|
||||
|
||||
3. `answer_question`: Choose this if the issue is just a question or request for information (no code changes needed), and either:
|
||||
- No answer has been provided yet by the AI agent.
|
||||
- Or the agent answered, but the human replied with follow-up questions/clarifications.
|
||||
Provide a clear, helpful response.
|
||||
|
||||
4. `close_issue`: Choose this ONLY if the AI agent previously answered a question AND the human has explicitly replied with a message confirming they are fully satisfied or explicitly instructing the agent to close the issue (e.g., "thanks, this answers my question", "looks good, you can close this", "close it"). If the human's response is a follow-up question, is ambiguous, or does not explicitly approve closing, you must NOT call this tool (call `answer_question` or `take_no_action` instead).
|
||||
Provide a polite closing comment.
|
||||
_PROMPTS_DIR: Path = Path(__file__).resolve().parent.parent / "prompts"
|
||||
|
||||
|
||||
5. `take_no_action`: Choose this if the issue is already resolved, or if we cannot proceed for another reason.
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
- You must call EXACTLY one tool. Do not guess, and do not output raw text instead of calling a tool.
|
||||
- The `plan`, `answer`, or `comment` argument you pass to the tool will be posted directly to Gitea. DO NOT include your thought process, reasoning, or internal details in those arguments. Keep them concise and professional.
|
||||
"""
|
||||
|
||||
NOTIFICATION_READER_SYSTEM_PROMPT: str = """
|
||||
You are a Gitea Notification Reader Agent. Your job is to analyze incoming Gitea notifications and determine how they should be routed.
|
||||
|
||||
Based on the notification subject, details, and conversation comments (if retrieved), you must choose and call exactly one of the following tools:
|
||||
|
||||
1. `process_issue`: Choose this if the notification refers to a Gitea issue that requires active intervention, planning, implementation, or answering a question by the AI agent.
|
||||
2. `process_pr`: Choose this if the notification refers to a Gitea Pull Request that requires active intervention, code reviews, updates, or merging by the AI agent.
|
||||
3. `skip_notification`: Choose this if:
|
||||
- The notification is irrelevant or does not require AI agent intervention.
|
||||
- It is a notification about an action taken by the AI agent itself (e.g. self-assigned, self-commented, self-opened).
|
||||
- The discussion is closed or resolved, or the notification is just informational (e.g. a simple status update that needs no reply).
|
||||
- You are unsure or think it does not fit the agent's scope. You must provide a clear reason for skipping.
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
- You must call EXACTLY one tool. Do not guess, and do not output raw text instead of calling a tool.
|
||||
- You can use the provided inspection tools (like get_issue, get_pull_request, get_issue_comments, get_pull_request_comments) to gather more details if the basic notification metadata is insufficient to make a decision.
|
||||
"""
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
def _load_prompt(filename: str) -> str:
|
||||
"""Load a prompt from an external text file in the prompts directory."""
|
||||
return (_PROMPTS_DIR / filename).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
COORDINATOR_SYSTEM_PROMPT: str = _load_prompt("coordinator_agent.txt")
|
||||
NOTIFICATION_READER_SYSTEM_PROMPT: str = _load_prompt("notification_agent.txt")
|
||||
PLANNING_AGENT_SYSTEM_PROMPT: str = _load_prompt("planning_agent.txt")
|
||||
|
||||
+50
-10
@@ -1,8 +1,11 @@
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from gitea.client import GiteaClient
|
||||
|
||||
logger: logging.Logger = logging.getLogger("gitea-file-tools")
|
||||
|
||||
|
||||
class FileTools:
|
||||
"""Tools for Gitea file/content operations."""
|
||||
@@ -67,18 +70,28 @@ class FileTools:
|
||||
local_path: str | None = self._resolve_local_path(owner, repo, path)
|
||||
if local_path and os.path.isfile(local_path):
|
||||
try:
|
||||
with open(local_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
with open(local_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
raw: str = f.read()
|
||||
return self._paginate_lines(raw, offset, limit)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
f"Local read failed for {owner}/{repo}/{path}: {exc}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
content = self._client.files.get_file_content(owner, repo, path)
|
||||
raw: str = "\n".join(content) if isinstance(content, list) else content
|
||||
return self._paginate_lines(raw, offset, limit)
|
||||
except Exception as e:
|
||||
return f"Error getting file content: {str(e)}"
|
||||
logger.error(
|
||||
f"Failed to get file content for {owner}/{repo}/{path}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not retrieve file '{path}' from {owner}/{repo}. "
|
||||
f"Verify the file path and branch are correct. Details: {e}"
|
||||
)
|
||||
|
||||
def get_file_content_with_ref(
|
||||
self,
|
||||
@@ -104,21 +117,34 @@ class FileTools:
|
||||
if os.path.isdir(local_repo):
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "-C", local_repo, "show", f"{ref}:{path}"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return self._paginate_lines(result.stdout, offset, limit)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
f"Local git show failed for {owner}/{repo}/{path}@{ref}: {exc}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
content = self._client.files.get_file_content(owner, repo, path, ref)
|
||||
raw: str = "\n".join(content) if isinstance(content, list) else content
|
||||
return self._paginate_lines(raw, offset, limit)
|
||||
except Exception as e:
|
||||
return f"Error getting file content: {str(e)}"
|
||||
logger.error(
|
||||
f"Failed to get file content for {owner}/{repo}/{path}@{ref}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not retrieve file '{path}' at ref '{ref}' from {owner}/{repo}. "
|
||||
f"Verify the file path and ref are correct. Details: {e}"
|
||||
)
|
||||
|
||||
def commit_file(
|
||||
self, owner: str, repo: str, path: str, message: str, content: str, branch: str
|
||||
@@ -127,7 +153,14 @@ class FileTools:
|
||||
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)}"
|
||||
logger.error(
|
||||
f"Failed to commit file '{path}' to {owner}/{repo}@{branch}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not commit file '{path}' to {owner}/{repo} on branch '{branch}'. "
|
||||
f"Check for conflicts or permission issues. Details: {e}"
|
||||
)
|
||||
|
||||
def update_file(
|
||||
self, owner: str, repo: str, path: str, message: str, content: str, branch: str
|
||||
@@ -136,4 +169,11 @@ class FileTools:
|
||||
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)}"
|
||||
logger.error(
|
||||
f"Failed to update file '{path}' in {owner}/{repo}@{branch}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not update file '{path}' in {owner}/{repo} on branch '{branch}'. "
|
||||
f"Check for conflicts or permission issues. Details: {e}"
|
||||
)
|
||||
|
||||
@@ -27,7 +27,14 @@ class IssueTools:
|
||||
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)}"
|
||||
logger.error(
|
||||
f"Failed to close issue #{issue_number} in {owner}/{repo}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not close issue #{issue_number} in {owner}/{repo}. "
|
||||
f"Check permissions or if the issue is already closed. Details: {e}"
|
||||
)
|
||||
|
||||
def get_issue_comments(
|
||||
self,
|
||||
@@ -58,7 +65,14 @@ class IssueTools:
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
return f"Error getting issue comments: {str(e)}"
|
||||
logger.error(
|
||||
f"Failed to get comments for issue #{issue_number} in {owner}/{repo}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not retrieve comments for issue #{issue_number} in {owner}/{repo}. "
|
||||
f"Verify the issue exists and you have access. Details: {e}"
|
||||
)
|
||||
|
||||
def list_assigned_issues(self) -> list[dict[str, Any]]:
|
||||
try:
|
||||
@@ -86,11 +100,18 @@ class IssueTools:
|
||||
try:
|
||||
issues = self._client.issues.list_repo_issues(owner, repo, state)
|
||||
if not issues:
|
||||
return f"No issues in {owner}/{repo}."
|
||||
return f"No {state} issues in {owner}/{repo}."
|
||||
summary = [f"#{issue.number}: {issue.title}" for issue in issues]
|
||||
return "\n".join(summary)
|
||||
except Exception as e:
|
||||
return f"Error listing issues: {str(e)}"
|
||||
logger.error(
|
||||
f"Failed to list {state} issues in {owner}/{repo}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not list issues for {owner}/{repo}. "
|
||||
f"Verify the repository exists and you have access. Details: {e}"
|
||||
)
|
||||
|
||||
def create_issue(
|
||||
self,
|
||||
@@ -107,7 +128,14 @@ class IssueTools:
|
||||
)
|
||||
return f"Issue #{issue.number} created successfully in {owner}/{repo}."
|
||||
except Exception as e:
|
||||
return f"Error creating issue: {str(e)}"
|
||||
logger.error(
|
||||
f"Failed to create issue '{title}' in {owner}/{repo}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not create issue '{title}' in {owner}/{repo}. "
|
||||
f"Check repository permissions and label/assignee names. Details: {e}"
|
||||
)
|
||||
|
||||
def add_label_to_issue(
|
||||
self, owner: str, repo: str, issue_number: int, label: str
|
||||
@@ -116,7 +144,14 @@ class IssueTools:
|
||||
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}"
|
||||
logger.error(
|
||||
f"Failed to add label '{label}' to issue #{issue_number} in {owner}/{repo}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not add label '{label}' to issue #{issue_number} in {owner}/{repo}. "
|
||||
f"Verify the label exists in the repository. Details: {e}"
|
||||
)
|
||||
|
||||
def add_comment_to_issue(
|
||||
self, owner: str, repo: str, issue_number: int, body: str
|
||||
@@ -125,4 +160,11 @@ class IssueTools:
|
||||
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}"
|
||||
logger.error(
|
||||
f"Failed to add comment to issue #{issue_number} in {owner}/{repo}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not add comment to issue #{issue_number} in {owner}/{repo}. "
|
||||
f"Verify the issue exists and you have write access. Details: {e}"
|
||||
)
|
||||
|
||||
+71
-11
@@ -40,11 +40,15 @@ class PRTools:
|
||||
def __init__(self, client: GiteaClient) -> None:
|
||||
self._client = client
|
||||
|
||||
def get_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel:
|
||||
def get_pull_request(
|
||||
self, owner: str, repo: str, pull_number: int
|
||||
) -> PullRequestModel:
|
||||
try:
|
||||
return self._client.prs.get_pull_request(owner, repo, pull_number)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting pull request #{pull_number}: {e}", exc_info=True)
|
||||
logger.error(
|
||||
f"Error getting pull request #{pull_number}: {e}", exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
def close_pull_request(self, owner: str, repo: str, pull_number: int) -> str:
|
||||
@@ -52,7 +56,14 @@ class PRTools:
|
||||
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)}"
|
||||
logger.error(
|
||||
f"Failed to close PR #{pull_number} in {owner}/{repo}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not close PR #{pull_number} in {owner}/{repo}. "
|
||||
f"Check permissions or if the PR is already closed. Details: {e}"
|
||||
)
|
||||
|
||||
def get_pull_request_comments(
|
||||
self,
|
||||
@@ -83,7 +94,14 @@ class PRTools:
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
return f"Error getting PR comments: {str(e)}"
|
||||
logger.error(
|
||||
f"Failed to get comments for PR #{pull_number} in {owner}/{repo}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not retrieve comments for PR #{pull_number} in {owner}/{repo}. "
|
||||
f"Verify the PR exists and you have access. Details: {e}"
|
||||
)
|
||||
|
||||
def list_assigned_pull_requests(self) -> list[dict[str, Any]]:
|
||||
try:
|
||||
@@ -111,11 +129,18 @@ class PRTools:
|
||||
try:
|
||||
prs = self._client.prs.list_repo_pull_requests(owner, repo, state)
|
||||
if not prs:
|
||||
return f"No PRs in {owner}/{repo}."
|
||||
return f"No {state} PRs in {owner}/{repo}."
|
||||
summary = [f"#{pr.number}: {pr.title}" for pr in prs]
|
||||
return "\n".join(summary)
|
||||
except Exception as e:
|
||||
return f"Error listing PRs: {str(e)}"
|
||||
logger.error(
|
||||
f"Failed to list {state} PRs in {owner}/{repo}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not list PRs for {owner}/{repo}. "
|
||||
f"Verify the repository exists and you have access. Details: {e}"
|
||||
)
|
||||
|
||||
def create_pull_request(
|
||||
self,
|
||||
@@ -156,7 +181,14 @@ class PRTools:
|
||||
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}"
|
||||
logger.error(
|
||||
f"Failed to add label '{label}' to PR #{pr_number} in {owner}/{repo}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not add label '{label}' to PR #{pr_number} in {owner}/{repo}. "
|
||||
f"Verify the label exists in the repository. Details: {e}"
|
||||
)
|
||||
|
||||
def get_pull_request_diff(
|
||||
self,
|
||||
@@ -177,7 +209,14 @@ class PRTools:
|
||||
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)}"
|
||||
logger.error(
|
||||
f"Failed to get diff for PR #{pull_number} in {owner}/{repo}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not retrieve diff for PR #{pull_number} in {owner}/{repo}. "
|
||||
f"The PR may have no changes or the API is unavailable. Details: {e}"
|
||||
)
|
||||
|
||||
def get_pull_request_patch(
|
||||
self,
|
||||
@@ -200,7 +239,14 @@ class PRTools:
|
||||
)
|
||||
return _truncate_diff(patch, max_chars, char_offset)
|
||||
except Exception as e:
|
||||
return f"Error getting PR patch: {str(e)}"
|
||||
logger.error(
|
||||
f"Failed to get patch for PR #{pull_number} in {owner}/{repo}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not retrieve patch for PR #{pull_number} in {owner}/{repo}. "
|
||||
f"The PR may have no changes or the API is unavailable. Details: {e}"
|
||||
)
|
||||
|
||||
def approve_pull_request(
|
||||
self, owner: str, repo: str, pull_number: int, comment: str
|
||||
@@ -209,7 +255,14 @@ class PRTools:
|
||||
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)}"
|
||||
logger.error(
|
||||
f"Failed to approve PR #{pull_number} in {owner}/{repo}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not approve PR #{pull_number} in {owner}/{repo}. "
|
||||
f"Check that you have review permissions. Details: {e}"
|
||||
)
|
||||
|
||||
def request_changes(
|
||||
self, owner: str, repo: str, pull_number: int, comment: str
|
||||
@@ -218,4 +271,11 @@ class PRTools:
|
||||
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)}"
|
||||
logger.error(
|
||||
f"Failed to request changes on PR #{pull_number} in {owner}/{repo}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
f"Error: Could not request changes on PR #{pull_number} in {owner}/{repo}. "
|
||||
f"Check that you have review permissions. Details: {e}"
|
||||
)
|
||||
|
||||
+31
-1
@@ -3,6 +3,7 @@ import logging
|
||||
import subprocess
|
||||
import base64
|
||||
import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from .config import GITEA_REPOS_ROOT, GITEA_URL, GITEA_TOKEN
|
||||
@@ -12,12 +13,27 @@ logger: logging.Logger = logging.getLogger("gitea-workspace")
|
||||
|
||||
|
||||
class WorkspaceManager:
|
||||
"""Manages local workspace for Gitea repositories."""
|
||||
"""Manages local workspace for Gitea repositories.
|
||||
|
||||
Uses per-repo threading locks to prevent concurrent git operations
|
||||
on the same repository from causing race conditions.
|
||||
"""
|
||||
|
||||
_repo_locks: dict[str, threading.Lock] = {}
|
||||
_locks_lock: threading.Lock = threading.Lock()
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.root_dir: Path = Path(GITEA_REPOS_ROOT).expanduser().resolve()
|
||||
self.root_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@classmethod
|
||||
def get_repo_lock(cls, repo_full_name: str) -> threading.Lock:
|
||||
"""Get or create a thread-safe lock for a specific repository."""
|
||||
with cls._locks_lock:
|
||||
if repo_full_name not in cls._repo_locks:
|
||||
cls._repo_locks[repo_full_name] = threading.Lock()
|
||||
return cls._repo_locks[repo_full_name]
|
||||
|
||||
def _configure_repo_user(self, repo_path: Path) -> None:
|
||||
try:
|
||||
client = GiteaClient()
|
||||
@@ -70,6 +86,12 @@ class WorkspaceManager:
|
||||
return f"{parsed.scheme}://{parsed.netloc}{path}/{repo_full_name}.git"
|
||||
|
||||
def sanitize_repo(self, repo_full_name: str, repo_path: Path) -> None:
|
||||
lock = self.get_repo_lock(repo_full_name)
|
||||
with lock:
|
||||
logger.debug(f"Acquired workspace lock for {repo_full_name} (sanitize)")
|
||||
self._sanitize_repo_inner(repo_full_name, repo_path)
|
||||
|
||||
def _sanitize_repo_inner(self, repo_full_name: str, repo_path: Path) -> None:
|
||||
try:
|
||||
auth_url = self._get_authenticated_url(repo_full_name)
|
||||
subprocess.run(
|
||||
@@ -146,6 +168,14 @@ class WorkspaceManager:
|
||||
) from e
|
||||
|
||||
def clone_repo(self, repo_full_name: str, clone_url: str | None = None) -> Path:
|
||||
lock = self.get_repo_lock(repo_full_name)
|
||||
with lock:
|
||||
logger.debug(f"Acquired workspace lock for {repo_full_name} (clone)")
|
||||
return self._clone_repo_inner(repo_full_name, clone_url)
|
||||
|
||||
def _clone_repo_inner(
|
||||
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():
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
You are an AI Coordinator. Your job is to analyze Gitea issues, read the conversation history, and determine the next action for the agent.
|
||||
|
||||
Based on the conversation state, you must choose and call exactly one of the following tools:
|
||||
|
||||
1. `propose_plan`: Choose this if code changes are needed to resolve the issue, and either:
|
||||
- No plan has been proposed yet by the AI agent.
|
||||
- Or a plan was proposed, but the human replied with feedback, corrections, or requests for changes, so we need to propose a revised plan.
|
||||
You must provide a detailed, step-by-step implementation plan (listing files to modify/create, specific changes to make, verification/test commands).
|
||||
|
||||
2. `start_implementation`: Choose this if:
|
||||
- A plan was previously proposed AND the human has clearly replied with approval/greenlight/go-ahead (e.g., "yes", "looks good", "ok", "go ahead", etc.).
|
||||
- Or there is an existing WIP PR or a PR with requested changes, and we need to resume implementing the changes.
|
||||
You must extract/summarize the approved plan, incorporating any human feedback.
|
||||
|
||||
3. `answer_question`: Choose this if the issue is just a question or request for information (no code changes needed), and either:
|
||||
- No answer has been provided yet by the AI agent.
|
||||
- Or the agent answered, but the human replied with follow-up questions/clarifications.
|
||||
Provide a clear, helpful response.
|
||||
|
||||
4. `close_issue`: Choose this ONLY if the AI agent previously answered a question AND the human has explicitly replied with a message confirming they are fully satisfied or explicitly instructing the agent to close the issue (e.g., "thanks, this answers my question", "looks good, you can close this", "close it"). If the human's response is a follow-up question, is ambiguous, or does not explicitly approve closing, you must NOT call this tool (call `answer_question` or `take_no_action` instead).
|
||||
Provide a polite closing comment.
|
||||
|
||||
|
||||
5. `take_no_action`: Choose this if the issue is already resolved, or if we cannot proceed for another reason.
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
- You must call EXACTLY one tool. Do not guess, and do not output raw text instead of calling a tool.
|
||||
- The `plan`, `answer`, or `comment` argument you pass to the tool will be posted directly to Gitea. DO NOT include your thought process, reasoning, or internal details in those arguments. Keep them concise and professional.
|
||||
@@ -0,0 +1,15 @@
|
||||
You are a Gitea Notification Reader Agent. Your job is to analyze incoming Gitea notifications and determine how they should be routed.
|
||||
|
||||
Based on the notification subject, details, and conversation comments (if retrieved), you must choose and call exactly one of the following tools:
|
||||
|
||||
1. `process_issue`: Choose this if the notification refers to a Gitea issue that requires active intervention, planning, implementation, or answering a question by the AI agent.
|
||||
2. `process_pr`: Choose this if the notification refers to a Gitea Pull Request that requires active intervention, code reviews, updates, or merging by the AI agent.
|
||||
3. `skip_notification`: Choose this if:
|
||||
- The notification is irrelevant or does not require AI agent intervention.
|
||||
- It is a notification about an action taken by the AI agent itself (e.g. self-assigned, self-commented, self-opened).
|
||||
- The discussion is closed or resolved, or the notification is just informational (e.g. a simple status update that needs no reply).
|
||||
- You are unsure or think it does not fit the agent's scope. You must provide a clear reason for skipping.
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
- You must call EXACTLY one tool. Do not guess, and do not output raw text instead of calling a tool.
|
||||
- You can use the provided inspection tools (like get_issue, get_pull_request, get_issue_comments, get_pull_request_comments) to gather more details if the basic notification metadata is insufficient to make a decision.
|
||||
@@ -0,0 +1,8 @@
|
||||
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.
|
||||
@@ -37,7 +37,8 @@ def test_get_file_content_failure() -> None:
|
||||
|
||||
file_tools: FileTools = FileTools(mock_client)
|
||||
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
||||
assert "Error getting file content: API Error" in res
|
||||
assert "Could not retrieve file" in res
|
||||
assert "path/to/file" in res
|
||||
|
||||
|
||||
def test_get_file_content_with_ref_string_success() -> None:
|
||||
@@ -73,7 +74,8 @@ def test_get_file_content_with_ref_failure() -> None:
|
||||
res: str = file_tools.get_file_content_with_ref(
|
||||
"owner", "repo", "path/to/file", "main"
|
||||
)
|
||||
assert "Error getting file content: API Error" in res
|
||||
assert "Could not retrieve file" in res
|
||||
assert "path/to/file" in res
|
||||
|
||||
|
||||
def test_commit_file_success() -> None:
|
||||
@@ -98,7 +100,8 @@ def test_commit_file_failure() -> None:
|
||||
res: str = file_tools.commit_file(
|
||||
"owner", "repo", "path/to/file", "msg", "content", "branch"
|
||||
)
|
||||
assert "Error committing file: API Error" in res
|
||||
assert "Could not commit file" in res
|
||||
assert "path/to/file" in res
|
||||
|
||||
|
||||
def test_update_file_success() -> None:
|
||||
@@ -123,11 +126,13 @@ def test_update_file_failure() -> None:
|
||||
res: str = file_tools.update_file(
|
||||
"owner", "repo", "path/to/file", "msg", "content", "branch"
|
||||
)
|
||||
assert "Error updating file: API Error" in res
|
||||
assert "Could not update file" in res
|
||||
assert "path/to/file" in res
|
||||
|
||||
|
||||
def test_get_file_content_uses_local_file_when_available(tmp_path: str) -> None:
|
||||
import os
|
||||
|
||||
mock_client = _create_mock_client()
|
||||
|
||||
repo_dir = tmp_path / "owner" / "repo"
|
||||
|
||||
@@ -56,7 +56,8 @@ def test_close_issue_failure() -> None:
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.close_issue("owner", "repo", 1)
|
||||
assert "Error closing issue: API Error" in res
|
||||
assert "Could not close issue" in res
|
||||
assert "owner/repo" in res
|
||||
|
||||
|
||||
def test_get_issue_comments_success() -> None:
|
||||
@@ -77,7 +78,8 @@ def test_get_issue_comments_failure() -> None:
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.get_issue_comments("owner", "repo", 1)
|
||||
assert "Error getting issue comments: API Error" in res
|
||||
assert "Could not retrieve comments" in res
|
||||
assert "owner/repo" in res
|
||||
|
||||
|
||||
def test_list_assigned_issues_success() -> None:
|
||||
@@ -120,7 +122,7 @@ def test_list_issues_empty() -> None:
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.list_issues("owner", "repo")
|
||||
assert res == "No issues in owner/repo."
|
||||
assert res == "No open issues in owner/repo."
|
||||
|
||||
|
||||
def test_list_issues_failure() -> None:
|
||||
@@ -129,7 +131,8 @@ def test_list_issues_failure() -> None:
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.list_issues("owner", "repo")
|
||||
assert "Error listing issues: API Error" in res
|
||||
assert "Could not list issues" in res
|
||||
assert "owner/repo" in res
|
||||
|
||||
|
||||
def test_create_issue_success() -> None:
|
||||
@@ -153,7 +156,8 @@ def test_create_issue_failure() -> None:
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.create_issue("owner", "repo", "Title", "Body")
|
||||
assert "Error creating issue: API Error" in res
|
||||
assert "Could not create issue" in res
|
||||
assert "Title" in res
|
||||
|
||||
|
||||
def test_add_label_to_issue_success() -> None:
|
||||
@@ -171,7 +175,8 @@ def test_add_label_to_issue_failure() -> None:
|
||||
|
||||
issue_tools: IssueTools = IssueTools(mock_client)
|
||||
res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug")
|
||||
assert "Error adding label to issue #1: API Error" in res
|
||||
assert "Could not add label" in res
|
||||
assert "bug" in res
|
||||
|
||||
|
||||
def test_add_comment_to_issue_success() -> None:
|
||||
@@ -189,4 +194,5 @@ def test_add_comment_to_issue_failure() -> None:
|
||||
|
||||
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
|
||||
assert "Could not add comment" in res
|
||||
assert "owner/repo" in res
|
||||
|
||||
+17
-9
@@ -58,7 +58,8 @@ def test_close_pull_request_failure() -> None:
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.close_pull_request("owner", "repo", 1)
|
||||
assert "Error closing pull request: API Error" in res
|
||||
assert "Could not close PR" in res
|
||||
assert "owner/repo" in res
|
||||
|
||||
|
||||
def test_get_pull_request_comments_success() -> None:
|
||||
@@ -79,7 +80,8 @@ def test_get_pull_request_comments_failure() -> None:
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.get_pull_request_comments("owner", "repo", 1)
|
||||
assert "Error getting PR comments: API Error" in res
|
||||
assert "Could not retrieve comments" in res
|
||||
assert "owner/repo" in res
|
||||
|
||||
|
||||
def test_list_assigned_pull_requests_success() -> None:
|
||||
@@ -124,7 +126,7 @@ def test_list_pull_requests_empty() -> None:
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.list_pull_requests("owner", "repo")
|
||||
assert res == "No PRs in owner/repo."
|
||||
assert res == "No open PRs in owner/repo."
|
||||
|
||||
|
||||
def test_list_pull_requests_failure() -> None:
|
||||
@@ -133,7 +135,8 @@ def test_list_pull_requests_failure() -> None:
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.list_pull_requests("owner", "repo")
|
||||
assert "Error listing PRs: API Error" in res
|
||||
assert "Could not list PRs" in res
|
||||
assert "owner/repo" in res
|
||||
|
||||
|
||||
def test_create_pull_request_success() -> None:
|
||||
@@ -179,7 +182,8 @@ def test_add_label_to_pr_failure() -> None:
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug")
|
||||
assert "Error adding label to PR #1: API Error" in res
|
||||
assert "Could not add label" in res
|
||||
assert "bug" in res
|
||||
|
||||
|
||||
def test_get_pull_request_diff_success() -> None:
|
||||
@@ -197,7 +201,8 @@ def test_get_pull_request_diff_failure() -> None:
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.get_pull_request_diff("owner", "repo", 1)
|
||||
assert "Error getting PR diff: API Error" in res
|
||||
assert "Could not retrieve diff" in res
|
||||
assert "owner/repo" in res
|
||||
|
||||
|
||||
def test_get_pull_request_patch_success() -> None:
|
||||
@@ -215,7 +220,8 @@ def test_get_pull_request_patch_failure() -> None:
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.get_pull_request_patch("owner", "repo", 1)
|
||||
assert "Error getting PR patch: API Error" in res
|
||||
assert "Could not retrieve patch" in res
|
||||
assert "owner/repo" in res
|
||||
|
||||
|
||||
def test_approve_pull_request_success() -> None:
|
||||
@@ -233,7 +239,8 @@ def test_approve_pull_request_failure() -> None:
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good")
|
||||
assert "Error approving PR: API Error" in res
|
||||
assert "Could not approve PR" in res
|
||||
assert "owner/repo" in res
|
||||
|
||||
|
||||
def test_request_changes_success() -> None:
|
||||
@@ -251,4 +258,5 @@ def test_request_changes_failure() -> None:
|
||||
|
||||
pr_tools: PRTools = PRTools(mock_client)
|
||||
res: str = pr_tools.request_changes("owner", "repo", 1, "bad")
|
||||
assert "Error requesting changes: API Error" in res
|
||||
assert "Could not request changes" in res
|
||||
assert "owner/repo" in res
|
||||
|
||||
Reference in New Issue
Block a user