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