refactor: remove GiteaTools facade, use focused tool classes directly

- Removed gitea/tools/gitea_tools.py (useless facade layer)
- Removed tests/test_gitea_tools.py (tests for removed facade)
- Updated core/dispatcher.py to use IssueTools, PRTools, FileTools, GitTools directly
- Updated core/orchestrator.py to use individual tool instances
- Updated main.py to create individual tool instances

This eliminates the triple layer of indirection (Issue 2.2 from bad_code.md)
where GiteaTools just delegated to IssueTools/PRTools/etc with zero added value.
This commit is contained in:
meeks
2026-07-16 13:15:18 +02:00
parent 26d69707c6
commit 9b63fbbcfc
5 changed files with 522 additions and 496 deletions
+424 -167
View File
@@ -13,23 +13,33 @@ from core.coordinator_agent import CoordinatorAgent, CoordinatorNoToolCalledErro
from gitea.tools.coding_tools import CodingTools from gitea.tools.coding_tools import CodingTools
from gitea.tools.research_tools import ResearchTools 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 gitea.client import GiteaClient
from core.coordinator_tools import CoordinatorTools from core.coordinator_tools import CoordinatorTools
from gitea.workspace import WorkspaceManager from gitea.workspace import WorkspaceManager
from gitea.config import AGENT_MODEL_ID from gitea.config import AGENT_MODEL_ID
from gitea.models import CommentModel, PullRequestFileModel, PullRequestModel, IssueModel from gitea.models import (
CommentModel,
PullRequestFileModel,
PullRequestModel,
IssueModel,
)
logger: logging.Logger = logging.getLogger("agent-dispatcher") logger: logging.Logger = logging.getLogger("agent-dispatcher")
CLOSE_KEYWORDS_PATTERN: re.Pattern[str] = re.compile( CLOSE_KEYWORDS_PATTERN: re.Pattern[str] = re.compile(
rf"\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+#(\d+)\b", 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.""" """Find an open pull request that addresses the given issue number."""
owner, repo_name = repo_full_name.split("/") owner, repo_name = repo_full_name.split("/")
try: try:
@@ -40,14 +50,18 @@ def _find_pr_for_issue_helper(client: GiteaClient, repo_full_name: str, issue_nu
return pr return pr
body = pr.body or "" body = pr.body or ""
title = pr.title 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): if any(int(m) == issue_number for m in matches):
return pr return pr
issue_ref_pattern = re.compile(rf"(?<!\w)#{issue_number}\b") issue_ref_pattern = re.compile(rf"(?<!\w)#{issue_number}\b")
if issue_ref_pattern.search(title) or issue_ref_pattern.search(body): if issue_ref_pattern.search(title) or issue_ref_pattern.search(body):
return pr return pr
except Exception as e: 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 return None
@@ -77,7 +91,7 @@ def _is_awaiting_reply_helper(comments: list[CommentModel], ai_username: str) ->
if "<!-- agent:awaiting-reply -->" not in body: if "<!-- agent:awaiting-reply -->" not in body:
return False return False
# Check if any human replied AFTER the last agent comment # Check if any human replied AFTER the last agent comment
for c in comments[last_agent_idx + 1:]: for c in comments[last_agent_idx + 1 :]:
if c.user and c.user.login not in agent_usernames: if c.user and c.user.login not in agent_usernames:
return False # Human replied — we can proceed return False # Human replied — we can proceed
return True # Agent signalled wait, no human replied yet return True # Agent signalled wait, no human replied yet
@@ -89,14 +103,20 @@ class TaskProcessor(ABC):
def __init__( def __init__(
self, self,
client: GiteaClient, client: GiteaClient,
tools: GiteaTools, issue_tools: IssueTools,
pr_tools: PRTools,
file_tools: FileTools,
git_tools: GitTools,
model_name: str, model_name: str,
repo: str, repo: str,
item: WorkItem, item: WorkItem,
ai_username: str, ai_username: str,
) -> None: ) -> None:
self.client = client 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.model_name = model_name
self.repo = repo self.repo = repo
self.item = item self.item = item
@@ -109,15 +129,15 @@ class TaskProcessor(ABC):
self.research_tools = ResearchTools() self.research_tools = ResearchTools()
self.planning_tools: list[Callable[..., Any]] = [ self.planning_tools: list[Callable[..., Any]] = [
self.tools.get_issue, self.issue_tools.get_issue,
self.tools.get_pull_request, self.pr_tools.get_pull_request,
self.tools.list_issues, self.issue_tools.list_issues,
self.tools.list_pull_requests, self.pr_tools.list_pull_requests,
self.tools.get_file_content, self.file_tools.get_file_content,
self.tools.get_issue_comments, self.issue_tools.get_issue_comments,
self.tools.get_pull_request_comments, self.pr_tools.get_pull_request_comments,
self.tools.get_pull_request_diff, self.pr_tools.get_pull_request_diff,
self.tools.get_pull_request_patch, self.pr_tools.get_pull_request_patch,
self.coding_tools.list_files, self.coding_tools.list_files,
self.coding_tools.read_file, self.coding_tools.read_file,
self.coding_tools.grep_search, self.coding_tools.grep_search,
@@ -128,30 +148,30 @@ class TaskProcessor(ABC):
] ]
self.coding_tools_list: list[Callable[..., Any]] = [ self.coding_tools_list: list[Callable[..., Any]] = [
self.tools.get_issue, self.issue_tools.get_issue,
self.tools.get_pull_request, self.pr_tools.get_pull_request,
self.tools.list_issues, self.issue_tools.list_issues,
self.tools.list_pull_requests, self.pr_tools.list_pull_requests,
self.tools.get_file_content, self.file_tools.get_file_content,
self.tools.create_pull_request, self.pr_tools.create_pull_request,
self.tools.update_pull_request, self.pr_tools.update_pull_request,
self.tools.add_label_to_issue, self.issue_tools.add_label_to_issue,
self.tools.add_label_to_pr, self.pr_tools.add_label_to_pr,
self.tools.create_branch, self.git_tools.create_branch,
self.tools.commit_file, self.file_tools.commit_file,
self.tools.create_issue, self.issue_tools.create_issue,
self.tools.add_comment_to_issue, self.issue_tools.add_comment_to_issue,
self.tools.close_issue, self.issue_tools.close_issue,
self.tools.close_pull_request, self.pr_tools.close_pull_request,
self.tools.get_issue_comments, self.issue_tools.get_issue_comments,
self.tools.get_pull_request_comments, self.pr_tools.get_pull_request_comments,
self.tools.add_comment, self.issue_tools.add_comment,
self.tools.add_label, self.issue_tools.add_label,
self.tools.update_file, self.file_tools.update_file,
self.tools.get_pull_request_diff, self.pr_tools.get_pull_request_diff,
self.tools.get_pull_request_patch, self.pr_tools.get_pull_request_patch,
self.tools.approve_pull_request, self.pr_tools.approve_pull_request,
self.tools.request_changes, self.pr_tools.request_changes,
self.coding_tools.list_files, self.coding_tools.list_files,
self.coding_tools.read_file, self.coding_tools.read_file,
self.coding_tools.write_file, self.coding_tools.write_file,
@@ -177,26 +197,40 @@ class PRTaskProcessor(TaskProcessor):
pr_details = pr_info.model_dump_json(indent=2) pr_details = pr_info.model_dump_json(indent=2)
pr_diff = "" pr_diff = ""
try: try:
pr_diff = self.client.get_pull_request_diff(self.owner, self.repo_name, pr_number) pr_diff = self.client.get_pull_request_diff(
self.owner, self.repo_name, pr_number
)
except Exception as e: except Exception as e:
logger.warning(f"Could not fetch PR diff for #{pr_number}: {e}") logger.warning(f"Could not fetch PR diff for #{pr_number}: {e}")
pr_diff = f"Error fetching diff: {e}" pr_diff = f"Error fetching diff: {e}"
pr_files: list[PullRequestFileModel] = [] pr_files: list[PullRequestFileModel] = []
try: try:
pr_files = self.client.get_pull_request_files(self.owner, self.repo_name, pr_number) pr_files = self.client.get_pull_request_files(
self.owner, self.repo_name, pr_number
)
except Exception as e: except Exception as e:
logger.warning(f"Error fetching files for PR #{pr_number}: {e}", exc_info=True) 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] = [] comments: list[CommentModel] = []
try: try:
comments = self.client.get_pull_request_comments(self.owner, self.repo_name, pr_number) comments = self.client.get_pull_request_comments(
self.owner, self.repo_name, pr_number
)
if not isinstance(comments, list): if not isinstance(comments, list):
comments = [] comments = []
except Exception as e: except Exception as e:
logger.warning(f"Error fetching comments for PR #{pr_number}: {e}", exc_info=True) logger.warning(
f"Error fetching comments for PR #{pr_number}: {e}", exc_info=True
)
reviews: list[dict[str, Any]] = [] reviews: list[dict[str, Any]] = []
try: try:
@@ -204,28 +238,35 @@ class PRTaskProcessor(TaskProcessor):
if not isinstance(reviews, list): if not isinstance(reviews, list):
reviews = [] reviews = []
except Exception as e: except Exception as e:
logger.warning(f"Error fetching reviews for PR #{pr_number}: {e}", exc_info=True) logger.warning(
f"Error fetching reviews for PR #{pr_number}: {e}", exc_info=True
)
timeline: list[dict[str, Any]] = [] timeline: list[dict[str, Any]] = []
for c in comments: for c in comments:
timeline.append({ timeline.append(
"timestamp": c.created_at or "", {
"user": c.user.login, "timestamp": c.created_at or "",
"type": "comment", "user": c.user.login,
"body": c.body, "type": "comment",
"by_ai": "Reviewed by AI Agent" in c.body or c.user.login == self.ai_username "body": c.body,
}) "by_ai": "Reviewed by AI Agent" in c.body
or c.user.login == self.ai_username,
}
)
for r in reviews: for r in reviews:
r_user = (r.get("user") or {}).get("login", "unknown") r_user = (r.get("user") or {}).get("login", "unknown")
r_body = r.get("body", "") r_body = r.get("body", "")
r_state = r.get("state", "") r_state = r.get("state", "")
timeline.append({ timeline.append(
"timestamp": r.get("submitted_at") or r.get("updated_at") or "", {
"user": r_user, "timestamp": r.get("submitted_at") or r.get("updated_at") or "",
"type": "review", "user": r_user,
"body": f"[{r_state}] {r_body}", "type": "review",
"by_ai": r_user == self.ai_username "body": f"[{r_state}] {r_body}",
}) "by_ai": r_user == self.ai_username,
}
)
timeline.sort(key=lambda x: x["timestamp"]) timeline.sort(key=lambda x: x["timestamp"])
@@ -237,15 +278,24 @@ class PRTaskProcessor(TaskProcessor):
logger.info(f"PR #{pr_number} already addressed by AI. Skipping.") 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." return f"SKIP: PR #{pr_number} has already been addressed by AI. No new action needed."
comments_str = "\n".join([ comments_str = (
f"- @{c.user.login} ({c.created_at}): {c.body}" "\n".join(
for c in comments [f"- @{c.user.login} ({c.created_at}): {c.body}" for c in comments]
]) if comments else "No comments yet." )
if comments
else "No comments yet."
)
reviews_str = "\n".join([ reviews_str = (
f"- @{(r.get('user') or {}).get('login')} ({r.get('submitted_at')}): [{r.get('state')}] {r.get('body')}" "\n".join(
for r in reviews [
]) if reviews else "No reviews yet." f"- @{(r.get('user') or {}).get('login')} ({r.get('submitted_at')}): [{r.get('state')}] {r.get('body')}"
for r in reviews
]
)
if reviews
else "No reviews yet."
)
connected_issues_ctx = "" connected_issues_ctx = ""
pr_body = pr_info.body or "" pr_body = pr_info.body or ""
@@ -255,11 +305,19 @@ class PRTaskProcessor(TaskProcessor):
for issue_num in linked_issues: for issue_num in linked_issues:
try: try:
issue = self.client.get_issue(self.owner, self.repo_name, issue_num) issue = self.client.get_issue(self.owner, self.repo_name, issue_num)
issue_comments = self.client.get_issue_comments(self.owner, self.repo_name, issue_num) issue_comments = self.client.get_issue_comments(
comments_list = "\n".join([ self.owner, self.repo_name, issue_num
f" - @{c.user.login} ({c.created_at}): {c.body}" )
for c in issue_comments comments_list = (
]) if issue_comments else " No comments yet." "\n".join(
[
f" - @{c.user.login} ({c.created_at}): {c.body}"
for c in issue_comments
]
)
if issue_comments
else " No comments yet."
)
issues_details.append( issues_details.append(
f"### Connected Issue #{issue_num}: {issue.title}\n" f"### Connected Issue #{issue_num}: {issue.title}\n"
@@ -270,14 +328,23 @@ class PRTaskProcessor(TaskProcessor):
except Exception as e: except Exception as e:
logger.warning(f"Could not fetch connected issue #{issue_num}: {e}") logger.warning(f"Could not fetch connected issue #{issue_num}: {e}")
if issues_details: 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_head_branch = (
pr_base_branch = pr_info.base.get('ref', 'unknown') if pr_info.base else "unknown" 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_state = pr_info.state
pr_created = pr_info.created_at or "unknown" 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: if is_fixing_pr:
instructions = ( instructions = (
@@ -287,7 +354,7 @@ class PRTaskProcessor(TaskProcessor):
f" 1. Checkout the PR's head branch: `git checkout {pr_head_branch}`\n" f" 1. Checkout the PR's head branch: `git checkout {pr_head_branch}`\n"
f" 2. Implement the requested fixes or changes on this branch.\n" f" 2. Implement the requested fixes or changes on this branch.\n"
f" 3. Verify your fixes and run verification/tests.\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." f" 5. After pushing, comment on the PR (using the `add_comment` tool) with a summary of the fixes implemented."
) )
else: else:
@@ -327,25 +394,38 @@ class PRTaskProcessor(TaskProcessor):
async def process(self, attempt_limit: int) -> str: async def process(self, attempt_limit: int) -> str:
try: try:
pr_detail = self.client.get_pull_request(self.owner, self.repo_name, self.item.task_number) pr_detail = self.client.get_pull_request(
self.owner, self.repo_name, self.item.task_number
)
except Exception as e: except Exception as e:
logger.warning(f"Error fetching PR #{self.item.task_number} detail: {e}") logger.warning(f"Error fetching PR #{self.item.task_number} detail: {e}")
return f"FAILED: Could not fetch details for PR #{self.item.task_number}." return f"FAILED: Could not fetch details for PR #{self.item.task_number}."
is_own_pr = (pr_detail.user and pr_detail.user.login == self.ai_username) is_own_pr = 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_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: 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}." return f"SKIP: Agent is not a requested reviewer on PR #{self.item.task_number}."
pr_comments = [] pr_comments = []
try: try:
pr_comments = self.client.get_pull_request_comments(self.owner, self.repo_name, self.item.task_number) pr_comments = self.client.get_pull_request_comments(
self.owner, self.repo_name, self.item.task_number
)
except Exception as e: except Exception as e:
logger.warning(f"Error fetching comments for PR #{self.item.task_number}: {e}", exc_info=True) 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): 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.") 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}." return f"SKIP: Awaiting human reply on PR #{self.item.task_number}."
base_mission = self._build_pr_mission(pr_detail, is_own_pr) base_mission = self._build_pr_mission(pr_detail, is_own_pr)
@@ -355,7 +435,9 @@ class PRTaskProcessor(TaskProcessor):
for attempt in range(1, attempt_limit + 1): for attempt in range(1, attempt_limit + 1):
try: 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 = ( planning_mission = (
f"PHASE 1: PLANNING PHASE\n\n" f"PHASE 1: PLANNING PHASE\n\n"
f"Your task is to research the problem, analyse the repository structure, and produce a detailed implementation plan.\n" f"Your task is to research the problem, analyse the repository structure, and produce a detailed implementation plan.\n"
@@ -369,9 +451,13 @@ class PRTaskProcessor(TaskProcessor):
f"4. Output your final plan clearly.\n" f"4. Output your final plan clearly.\n"
) )
planning_agent = PlanningAgent(self.model_name) 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 = ( coding_mission = (
f"PHASE 2: EXECUTION/CODING PHASE\n\n" f"PHASE 2: EXECUTION/CODING PHASE\n\n"
f"You must now implement the changes based on the following plan:\n" f"You must now implement the changes based on the following plan:\n"
@@ -380,10 +466,14 @@ class PRTaskProcessor(TaskProcessor):
f"Follow the workflow to implement changes, verify, and complete PR review/updates.\n" f"Follow the workflow to implement changes, verify, and complete PR review/updates.\n"
) )
coding_agent = CodingAgent(self.model_name) 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 return response
except Exception as e: 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: if attempt == attempt_limit:
return f"FAILED after {attempt_limit} attempts: {str(e)}" return f"FAILED after {attempt_limit} attempts: {str(e)}"
return f"FAILED: PR #{self.item.task_number} not processed." return f"FAILED: PR #{self.item.task_number} not processed."
@@ -402,15 +492,24 @@ class IssueTaskProcessor(TaskProcessor):
comments: list[CommentModel] = [] comments: list[CommentModel] = []
try: try:
comments = self.client.get_issue_comments(self.owner, self.repo_name, issue_number) comments = self.client.get_issue_comments(
self.owner, self.repo_name, issue_number
)
except Exception as e: except Exception as e:
logger.warning(f"Error fetching comments for issue #{issue_number}: {e}", exc_info=True) 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" labels_str = (
comments_str = "\n".join([ f"Labels: {', '.join(issue_labels)}" if issue_labels else "Labels: none"
f"- @{c.user.login} ({c.created_at}): {c.body}" )
for c in comments comments_str = (
]) if comments else "No comments yet." "\n".join(
[f"- @{c.user.login} ({c.created_at}): {c.body}" for c in comments]
)
if comments
else "No comments yet."
)
return ( return (
f"Your mission is to resolve issue #{issue_number} in {self.repo}.\n\n" f"Your mission is to resolve issue #{issue_number} in {self.repo}.\n\n"
@@ -449,7 +548,9 @@ class IssueTaskProcessor(TaskProcessor):
async def process(self, attempt_limit: int) -> str: async def process(self, attempt_limit: int) -> str:
# Check if there is an existing PR for the issue # 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 is_wip = False
has_request_changes = False has_request_changes = False
@@ -459,32 +560,56 @@ class IssueTaskProcessor(TaskProcessor):
is_wip = title_upper.startswith("WIP:") or "[WIP]" in title_upper is_wip = title_upper.startswith("WIP:") or "[WIP]" in title_upper
try: try:
reviews = self.client.get_pr_reviews(self.owner, self.repo_name, existing_pr.number) reviews = self.client.get_pr_reviews(
has_request_changes = any(r.get("state") == "REQUEST_CHANGES" for r in 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: 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: 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." return f"SKIP: A pull request (PR #{existing_pr.number}) addressing issue #{self.item.task_number} already exists."
# Check comments on issue and PR # Check comments on issue and PR
issue_comments = [] issue_comments = []
try: try:
issue_comments = self.client.get_issue_comments(self.owner, self.repo_name, self.item.task_number) issue_comments = self.client.get_issue_comments(
self.owner, self.repo_name, self.item.task_number
)
except Exception as e: except Exception as e:
logger.warning(f"Error fetching comments for issue #{self.item.task_number}: {e}", exc_info=True) logger.warning(
f"Error fetching comments for issue #{self.item.task_number}: {e}",
exc_info=True,
)
pr_comments = [] pr_comments = []
if existing_pr: if existing_pr:
try: try:
pr_comments = self.client.get_pull_request_comments(self.owner, self.repo_name, existing_pr.number) pr_comments = self.client.get_pull_request_comments(
self.owner, self.repo_name, existing_pr.number
)
except Exception as e: except Exception as e:
logger.warning(f"Error fetching comments for PR #{existing_pr.number}: {e}", exc_info=True) logger.warning(
f"Error fetching comments for PR #{existing_pr.number}: {e}",
exc_info=True,
)
if _is_awaiting_reply_helper(issue_comments, self.ai_username) or _is_awaiting_reply_helper(pr_comments, self.ai_username): if _is_awaiting_reply_helper(
logger.info(f"Issue #{self.item.task_number}: awaiting human reply. Skipping.") issue_comments, self.ai_username
return f"SKIP: Awaiting human reply on issue #{self.item.task_number} or PR." ) 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 issue_info = self.item.task_info
if not isinstance(issue_info, IssueModel): if not isinstance(issue_info, IssueModel):
@@ -492,23 +617,48 @@ class IssueTaskProcessor(TaskProcessor):
title = issue_info.title title = issue_info.title
issue_body = issue_info.body or "No description provided." issue_body = issue_info.body or "No description provided."
issue_comments_str = "\n".join([ issue_comments_str = (
f"- @{c.user.login} ({c.created_at}): {c.body}" for c in issue_comments "\n".join(
]) if issue_comments else "No comments yet." [
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." pr_info_str = "No existing PR."
if existing_pr: if existing_pr:
pr_comments_str = "\n".join([ pr_comments_str = (
f"- @{c.user.login} ({c.created_at}): {c.body}" for c in pr_comments "\n".join(
]) if pr_comments else "No PR comments yet." [
f"- @{c.user.login} ({c.created_at}): {c.body}"
for c in pr_comments
]
)
if pr_comments
else "No PR comments yet."
)
try: try:
reviews = self.client.get_pr_reviews(self.owner, self.repo_name, existing_pr.number) reviews = self.client.get_pr_reviews(
reviews_str = "\n".join([ self.owner, self.repo_name, existing_pr.number
f"- @{r.get('user', {}).get('login')} ({r.get('submitted_at')}): [{r.get('state')}] {r.get('body')}" )
for r in reviews reviews_str = (
]) if reviews else "No reviews yet." "\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 as e: except Exception as e:
logger.warning(f"Error fetching reviews for PR #{existing_pr.number}: {e}", exc_info=True) logger.warning(
f"Error fetching reviews for PR #{existing_pr.number}: {e}",
exc_info=True,
)
reviews_str = "No reviews available." reviews_str = "No reviews available."
pr_info_str = ( pr_info_str = (
f"PR Number: #{existing_pr.number}\n" f"PR Number: #{existing_pr.number}\n"
@@ -531,12 +681,18 @@ class IssueTaskProcessor(TaskProcessor):
try: try:
coord_tools = CoordinatorTools() coord_tools = CoordinatorTools()
coordinator_agent = CoordinatorAgent(self.model_name) coordinator_agent = CoordinatorAgent(self.model_name)
logger.info(f"Analyzing conversation state for issue #{self.item.task_number}...") logger.info(
await coordinator_agent.decide_action(state_analysis_mission, self.planning_tools, coord_tools) 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 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": if action == "PROPOSE_PLAN":
comment_body = coord_tools.arguments.get("comment_body", "") comment_body = coord_tools.arguments.get("comment_body", "")
@@ -549,7 +705,9 @@ class IssueTaskProcessor(TaskProcessor):
f"<!-- agent:plan-proposal -->\n" f"<!-- agent:plan-proposal -->\n"
f"<!-- agent:awaiting-reply -->" f"<!-- agent:awaiting-reply -->"
) )
self.client.add_comment(self.owner, self.repo_name, self.item.task_number, comment_body) self.client.add_comment(
self.owner, self.repo_name, self.item.task_number, comment_body
)
return f"POSTED_COMMENT: PROPOSE_PLAN comment posted to issue #{self.item.task_number}." return f"POSTED_COMMENT: PROPOSE_PLAN comment posted to issue #{self.item.task_number}."
elif action == "ANSWER_QUESTION": elif action == "ANSWER_QUESTION":
@@ -562,17 +720,27 @@ class IssueTaskProcessor(TaskProcessor):
f"<!-- agent:question-response -->\n" f"<!-- agent:question-response -->\n"
f"<!-- agent:awaiting-reply -->" f"<!-- agent:awaiting-reply -->"
) )
self.client.add_comment(self.owner, self.repo_name, self.item.task_number, comment_body) self.client.add_comment(
self.owner, self.repo_name, self.item.task_number, comment_body
)
return f"POSTED_COMMENT: ANSWER_QUESTION comment posted to issue #{self.item.task_number}." return f"POSTED_COMMENT: ANSWER_QUESTION comment posted to issue #{self.item.task_number}."
elif action == "CLOSE_ISSUE": elif action == "CLOSE_ISSUE":
comment = coord_tools.arguments.get("comment", "Closing the issue as resolved.") comment = coord_tools.arguments.get(
self.client.add_comment(self.owner, self.repo_name, self.item.task_number, comment) "comment", "Closing the issue as resolved."
self.client.close_issue(self.owner, self.repo_name, self.item.task_number) )
self.client.add_comment(
self.owner, self.repo_name, self.item.task_number, comment
)
self.client.close_issue(
self.owner, self.repo_name, self.item.task_number
)
return f"CLOSED_ISSUE: Issue #{self.item.task_number} closed." return f"CLOSED_ISSUE: Issue #{self.item.task_number} closed."
elif action == "NO_ACTION": 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": elif action == "EXECUTE_PLAN":
approved_plan = coord_tools.arguments.get("approved_plan", "") approved_plan = coord_tools.arguments.get("approved_plan", "")
@@ -581,10 +749,16 @@ class IssueTaskProcessor(TaskProcessor):
if pr_to_use: if pr_to_use:
branch_name = pr_to_use.head.get("ref", "") 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: else:
logger.info(f"Creating new WIP PR for issue #{self.item.task_number}") logger.info(
clean_title = re.sub(r'[^a-zA-Z0-9\s-]', '', title).strip().lower() 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] title_words = clean_title.split()[:5]
desc_suffix = "-".join(title_words) desc_suffix = "-".join(title_words)
if not desc_suffix: if not desc_suffix:
@@ -592,26 +766,77 @@ class IssueTaskProcessor(TaskProcessor):
branch_name = f"fix/issue-{self.item.task_number}-{desc_suffix}" branch_name = f"fix/issue-{self.item.task_number}-{desc_suffix}"
try: try:
subprocess.run(["git", "checkout", "master"], cwd=str(self.repo_path), check=True) subprocess.run(
subprocess.run(["git", "pull", "origin", "master"], cwd=str(self.repo_path), check=True) ["git", "checkout", "master"],
subprocess.run(["git", "branch", "-D", branch_name], cwd=str(self.repo_path), stderr=subprocess.DEVNULL) cwd=str(self.repo_path),
subprocess.run(["git", "checkout", "-b", branch_name], cwd=str(self.repo_path), check=True) 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) subprocess.run(
["git", "pull", "origin", "master"],
pr_title = f"WIP: {title}" cwd=str(self.repo_path),
pr_description = f"Work in progress for issue #{self.item.task_number}." check=True,
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", "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}" pr_title = f"WIP: {title}"
start_comment = f"Started work on PR #{pr_to_use.number} ({pr_link})." pr_description = (
self.client.add_comment(self.owner, self.repo_name, self.item.task_number, start_comment) 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,
)
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.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: 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}" return f"FAILED to create WIP PR: {e}"
base_mission = self._build_issue_mission(issue_info, branch_name) base_mission = self._build_issue_mission(issue_info, branch_name)
@@ -636,18 +861,28 @@ class IssueTaskProcessor(TaskProcessor):
f" - web_search(query, time_range, categories) — search the web via SearXNG/DuckDuckGo\n" f" - web_search(query, time_range, categories) — search the web via SearXNG/DuckDuckGo\n"
f" - fetch_url(url) — read any documentation page in full\n" 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) 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(
logger.info(f"Agent response for issue #{self.item.task_number}: {response}") coding_mission, self.coding_tools_list
)
logger.info(
f"Agent response for issue #{self.item.task_number}: {response}"
)
return response return response
except CoordinatorNoToolCalledError as e: 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: if attempt == attempt_limit:
return f"FAILED: Coordinator did not call any tools after {attempt_limit} attempts." return f"FAILED: Coordinator did not call any tools after {attempt_limit} attempts."
except Exception as e: 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: if attempt == attempt_limit:
return f"FAILED after {attempt_limit} attempts: {str(e)}" return f"FAILED after {attempt_limit} attempts: {str(e)}"
return f"FAILED: Issue #{self.item.task_number} not processed." return f"FAILED: Issue #{self.item.task_number} not processed."
@@ -659,12 +894,18 @@ class AgentDispatcher:
def __init__( def __init__(
self, self,
client: GiteaClient, client: GiteaClient,
tools: GiteaTools, issue_tools: IssueTools,
pr_tools: PRTools,
file_tools: FileTools,
git_tools: GitTools,
model_name: str = AGENT_MODEL_ID, model_name: str = AGENT_MODEL_ID,
max_retries: int = 2, max_retries: int = 2,
) -> None: ) -> None:
self._client = client 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._model_name = model_name
self._max_retries = max_retries self._max_retries = max_retries
@@ -693,7 +934,10 @@ class AgentDispatcher:
if item.task_type == "pr": if item.task_type == "pr":
processor = PRTaskProcessor( processor = PRTaskProcessor(
client=self._client, 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, model_name=self._model_name,
repo=repo, repo=repo,
item=item, item=item,
@@ -702,7 +946,10 @@ class AgentDispatcher:
elif item.task_type == "issue": elif item.task_type == "issue":
processor = IssueTaskProcessor( processor = IssueTaskProcessor(
client=self._client, 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, model_name=self._model_name,
repo=repo, repo=repo,
item=item, item=item,
@@ -713,14 +960,18 @@ class AgentDispatcher:
results.append(f"SKIP: Unknown task type {item.task_type}") results.append(f"SKIP: Unknown task type {item.task_type}")
continue 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) result = await processor.process(attempt_limit=self._max_retries)
results.append(result) results.append(result)
return results return results
# Backward compatibility helper methods for unit tests # 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) return _find_pr_for_issue_helper(self._client, repo_full_name, issue_number)
def _is_awaiting_reply(self, comments: list[CommentModel]) -> bool: def _is_awaiting_reply(self, comments: list[CommentModel]) -> bool:
@@ -738,7 +989,10 @@ class AgentDispatcher:
raise RuntimeError("No authenticated user found.") raise RuntimeError("No authenticated user found.")
processor = PRTaskProcessor( processor = PRTaskProcessor(
client=self._client, 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, model_name=self._model_name,
repo=item.repo_full_name, repo=item.repo_full_name,
item=item, item=item,
@@ -755,7 +1009,10 @@ class AgentDispatcher:
raise RuntimeError("No authenticated user found.") raise RuntimeError("No authenticated user found.")
processor = IssueTaskProcessor( processor = IssueTaskProcessor(
client=self._client, 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, model_name=self._model_name,
repo=item.repo_full_name, repo=item.repo_full_name,
item=item, item=item,
+68 -28
View File
@@ -11,10 +11,16 @@ from core.queue import WorkQueue, WorkItem
from core.dispatcher import AgentDispatcher from core.dispatcher import AgentDispatcher
from gitea.client import GiteaClient from gitea.client import GiteaClient
from gitea.models import IssueModel, PullRequestModel, RepositoryModel 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.config import AGENT_MODEL_ID, AGENT_MAX_RETRIES
from gitea.workspace import WorkspaceManager from gitea.workspace import WorkspaceManager
from core.notification_agent import NotificationReaderAgent, NotificationNoToolCalledError from core.notification_agent import (
NotificationReaderAgent,
NotificationNoToolCalledError,
)
from core.notification_tools import NotificationTools from core.notification_tools import NotificationTools
logger: logging.Logger = logging.getLogger("agent-orchestrator") logger: logging.Logger = logging.getLogger("agent-orchestrator")
@@ -26,19 +32,32 @@ class AgentOrchestrator:
def __init__( def __init__(
self, self,
client: GiteaClient, client: GiteaClient,
tools: GiteaTools, issue_tools: IssueTools,
pr_tools: PRTools,
file_tools: FileTools,
git_tools: GitTools,
model_name: str = AGENT_MODEL_ID, model_name: str = AGENT_MODEL_ID,
max_retries: int = AGENT_MAX_RETRIES, max_retries: int = AGENT_MAX_RETRIES,
) -> None: ) -> None:
self._client = client 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._model_name = model_name
self._work_queue = WorkQueue() self._work_queue = WorkQueue()
self._dispatcher = AgentDispatcher(client, tools, model_name, max_retries) self._dispatcher = AgentDispatcher(
client,
issue_tools,
pr_tools,
file_tools,
git_tools,
model_name,
max_retries,
)
self._notification_reader = NotificationReaderAgent(model_name) self._notification_reader = NotificationReaderAgent(model_name)
self._max_retries = max_retries self._max_retries = max_retries
def _get_state_file_path(self) -> Path: def _get_state_file_path(self) -> Path:
"""Get the path to the persistent state file.""" """Get the path to the persistent state file."""
return Path(__file__).parent.parent / "agent_state.json" return Path(__file__).parent.parent / "agent_state.json"
@@ -67,7 +86,9 @@ class AgentOrchestrator:
async def poll_and_dispatch(self) -> None: async def poll_and_dispatch(self) -> None:
"""Poll Gitea unread notifications, enqueue them, and dispatch to agent.""" """Poll Gitea unread notifications, enqueue them, and dispatch to agent."""
last_checked = self._read_last_checked() 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.list_unread_notifications(since=last_checked)
@@ -80,10 +101,10 @@ class AgentOrchestrator:
# Filter and enqueue tasks from notifications # Filter and enqueue tasks from notifications
latest_timestamp = last_checked latest_timestamp = last_checked
inspection_tools = [ inspection_tools = [
self._tools.get_issue, self._issue_tools.get_issue,
self._tools.get_pull_request, self._pr_tools.get_pull_request,
self._tools.get_issue_comments, self._issue_tools.get_issue_comments,
self._tools.get_pull_request_comments, self._pr_tools.get_pull_request_comments,
] ]
for n in notifications: for n in notifications:
@@ -106,7 +127,9 @@ class AgentOrchestrator:
try: try:
task_number = int(subj_url.rstrip("/").split("/")[-1]) task_number = int(subj_url.rstrip("/").split("/")[-1])
except (ValueError, IndexError): 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 continue
# Run NotificationReaderAgent to pre-screen the notification # Run NotificationReaderAgent to pre-screen the notification
@@ -125,21 +148,27 @@ class AgentOrchestrator:
attempt += 1 attempt += 1
try: try:
await self._notification_reader.decide_notification( await self._notification_reader.decide_notification(
mission, mission, inspection_tools, notification_tools
inspection_tools,
notification_tools
) )
success = True success = True
break break
except NotificationNoToolCalledError as e: 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": if not success or notification_tools.action == "SKIP":
reason = notification_tools.arguments.get("reason", "Failed to call routing tool / default skip") reason = notification_tools.arguments.get(
logger.info(f"Skipping notification {notification_id} for {repo_full_name}#{task_number}. Reason: {reason}") "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: if notification_id is not None:
self._client.mark_notification_as_read(notification_id) self._client.mark_notification_as_read(notification_id)
logger.info(f"Marked skipped Gitea notification thread {notification_id} as read.") logger.info(
f"Marked skipped Gitea notification thread {notification_id} as read."
)
continue continue
# Route based on decided action # Route based on decided action
@@ -147,7 +176,9 @@ class AgentOrchestrator:
try: try:
issue = self._client.get_issue(owner, repo_name, task_number) issue = self._client.get_issue(owner, repo_name, task_number)
if issue.repository is None: 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( item = WorkItem(
repo_full_name=repo_full_name, repo_full_name=repo_full_name,
@@ -155,16 +186,20 @@ class AgentOrchestrator:
task_number=task_number, task_number=task_number,
task_info=issue, task_info=issue,
notification_id=notification_id, notification_id=notification_id,
priority=0 priority=0,
) )
self._work_queue.enqueue(item) self._work_queue.enqueue(item)
except Exception as e: 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": elif notification_tools.action == "PROCESS_PR":
try: try:
pr = self._client.get_pull_request(owner, repo_name, task_number) pr = self._client.get_pull_request(owner, repo_name, task_number)
if pr.repository is None: 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( item = WorkItem(
repo_full_name=repo_full_name, repo_full_name=repo_full_name,
@@ -172,12 +207,13 @@ class AgentOrchestrator:
task_number=task_number, task_number=task_number,
task_info=pr, task_info=pr,
notification_id=notification_id, notification_id=notification_id,
priority=0 priority=0,
) )
self._work_queue.enqueue(item) self._work_queue.enqueue(item)
except Exception as e: 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 # Process enqueued work
if not self._work_queue.is_empty: if not self._work_queue.is_empty:
@@ -212,7 +248,11 @@ class AgentOrchestrator:
for i, result in enumerate(results): for i, result in enumerate(results):
item = work_items[i] 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: if item.notification_id is not None:
self._client.mark_notification_as_read(item.notification_id) self._client.mark_notification_as_read(item.notification_id)
logger.info(f"Marked Gitea notification thread {item.notification_id} as read.") logger.info(
f"Marked Gitea notification thread {item.notification_id} as read."
)
-177
View File
@@ -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)
+30 -11
View File
@@ -7,7 +7,10 @@ from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from gitea.client import GiteaClient 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 gitea.config import AGENT_MODEL_ID, AGENT_MAX_RETRIES
from core.orchestrator import AgentOrchestrator from core.orchestrator import AgentOrchestrator
@@ -38,12 +41,11 @@ file_handler = RotatingFileHandler(LOG_FILE, maxBytes=5 * 1024 * 1024, backupCou
file_handler.setFormatter(JSONFormatter()) file_handler.setFormatter(JSONFormatter())
stream_handler = logging.StreamHandler() stream_handler = logging.StreamHandler()
stream_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) stream_handler.setFormatter(
logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
logging.basicConfig(
level=logging.INFO,
handlers=[file_handler, stream_handler]
) )
logging.basicConfig(level=logging.INFO, handlers=[file_handler, stream_handler])
logger: logging.Logger = logging.getLogger("coding-agent") logger: logging.Logger = logging.getLogger("coding-agent")
@@ -64,14 +66,27 @@ async def main() -> None:
raise RuntimeError("No authenticated user found.") raise RuntimeError("No authenticated user found.")
logger.info(f"Authenticated as user: {user.login}") logger.info(f"Authenticated as user: {user.login}")
except Exception as e: except Exception as e:
logger.critical(f"Critical initialization error: No authenticated user found. {e}") logger.critical(
f"Critical initialization error: No authenticated user found. {e}"
)
raise SystemExit(1) raise SystemExit(1)
tools: GiteaTools = GiteaTools(client) 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 model_name: str = AGENT_MODEL_ID
# Initialize orchestrator # 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("--- Autonomous Coding Agent Active ---")
logger.info(f"Model: {model_name}") logger.info(f"Model: {model_name}")
@@ -100,11 +115,15 @@ async def main() -> None:
break break
except Exception as e: except Exception as e:
consecutive_errors += 1 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 too many consecutive errors, wait longer
if consecutive_errors >= max_consecutive_errors: 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) await asyncio.sleep(300)
consecutive_errors = 0 consecutive_errors = 0
else: else:
-113
View File
@@ -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")