From a0c247b152ba1fbafcd5fd8992aa3356dc6763fc Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 29 Jun 2026 20:35:46 +0200 Subject: [PATCH] feat: redesign issue processing to planning and question board with WIP PR workflow (#3) Co-authored-by: Michael Reviewed-on: https://gitea.meeks.freeddns.org/michael/coding-agent-gitea/pulls/3 --- core/coding_prompt.py | 6 +- core/dispatcher.py | 353 +++++++++++++++++++++++++++++------ core/interfaces.py | 11 ++ gitea/client.py | 26 +++ gitea/models.py | 1 + gitea/tools/gitea_tools.py | 12 ++ gitea/tools/pr_tools.py | 15 ++ tests/test_best_practices.py | 21 ++- tests/test_dispatcher.py | 273 ++++++++++++++++++++++++++- tests/test_file_tools.py | 8 +- 10 files changed, 658 insertions(+), 68 deletions(-) diff --git a/core/coding_prompt.py b/core/coding_prompt.py index fd12e47..8c53bae 100644 --- a/core/coding_prompt.py +++ b/core/coding_prompt.py @@ -50,10 +50,10 @@ Every change MUST follow this exact workflow: git push origin feat/descriptive-name ``` -6. **Create PR**: Always create a PR linking the issue using the dedicated `create_pull_request` tool: +6. **Create / Update PR**: Always create or update PRs using the dedicated tools `create_pull_request` and `update_pull_request`. Do NOT use Gitea's `tea` CLI or GitHub's `gh` CLI via `run_command` (they run interactively and will freeze/hang indefinitely). - Call `create_pull_request` directly. - where the PR description follows the template below. + Call the tools directly. + The PR description/body MUST follow the template below. ### 📋 PR TEMPLATE (MANDATORY) Every PR body MUST use this exact template: diff --git a/core/dispatcher.py b/core/dispatcher.py index 7ee4f94..8c260dd 100644 --- a/core/dispatcher.py +++ b/core/dispatcher.py @@ -127,6 +127,7 @@ class AgentDispatcher: 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, @@ -164,29 +165,76 @@ class AgentDispatcher: os.chdir(str(repo_path)) changed_dir = True try: + # Get authenticated username for reviewer filter + ai_username = "meeks-ai" + try: + user = self._client.get_authenticated_user() + if user: + ai_username = user.login + except Exception: + pass + for item in work_items: + owner, repo_name = repo.split("/") + if item.task_type == "issue": existing_pr = self._find_pr_for_issue(repo, item.task_number) - if existing_pr: - logger.info(f"Issue #{item.task_number} already has open PR #{existing_pr.number}. Skipping.") - results.append(f"SKIP: A pull request (PR #{existing_pr.number}) addressing issue #{item.task_number} already exists.") - continue + is_wip = False + has_request_changes = False - # Check if we're waiting for a human reply before acting - owner, repo_name = repo.split("/") + if existing_pr: + if existing_pr.title: + title_upper = existing_pr.title.strip().upper() + is_wip = title_upper.startswith("WIP:") or "[WIP]" in title_upper + + try: + reviews = self._client.get_pr_reviews(owner, 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}") + + if not is_wip and not has_request_changes: + logger.info(f"Issue #{item.task_number} already has open PR #{existing_pr.number}. Skipping.") + results.append(f"SKIP: A pull request (PR #{existing_pr.number}) addressing issue #{item.task_number} already exists.") + continue + + # Check comments on issue and PR issue_comments = [] try: issue_comments = self._client.get_issue_comments(owner, repo_name, item.task_number) except Exception: pass - if self._is_awaiting_reply(issue_comments): - logger.info(f"Issue #{item.task_number}: agent asked a question and is awaiting a human reply. Skipping.") - results.append(f"SKIP: Awaiting human reply on issue #{item.task_number}.") + + pr_comments = [] + if existing_pr: + try: + pr_comments = self._client.get_pull_request_comments(owner, repo_name, existing_pr.number) + except Exception: + pass + + if self._is_awaiting_reply(issue_comments) or self._is_awaiting_reply(pr_comments): + logger.info(f"Issue #{item.task_number}: awaiting human reply. Skipping.") + results.append(f"SKIP: Awaiting human reply on issue #{item.task_number} or PR.") continue elif item.task_type == "pr": + # Check if the agent is requested/assigned as a reviewer + try: + pr_detail = self._client.get_pull_request(owner, repo_name, item.task_number) + except Exception as e: + logger.warning(f"Error fetching PR #{item.task_number} detail: {e}") + results.append(f"FAILED: Could not fetch details for PR #{item.task_number}.") + continue + + is_own_pr = (pr_detail.user and pr_detail.user.login == ai_username) + is_requested_reviewer = any(r.login == ai_username for r in pr_detail.requested_reviewers) + + if not is_own_pr and not is_requested_reviewer: + logger.info(f"PR #{item.task_number}: Agent is not a requested reviewer. Skipping.") + results.append(f"SKIP: Agent is not a requested reviewer on PR #{item.task_number}.") + continue + # Check if we're waiting for a human reply before acting on a PR - owner, repo_name = repo.split("/") pr_comments = [] try: pr_comments = self._client.get_pull_request_comments(owner, repo_name, item.task_number) @@ -199,55 +247,254 @@ class AgentDispatcher: for attempt in range(1, self._max_retries + 1): try: - if item.task_type == "issue": - base_mission = self._build_issue_mission(item) - else: + if item.task_type == "pr": + # Process PR as before base_mission = self._build_pr_mission(item) + if base_mission.startswith("SKIP:"): + logger.info(f"Skipping task #{item.task_number}: {base_mission}") + results.append(base_mission) + break - if base_mission.startswith("SKIP:"): - logger.info(f"Skipping task #{item.task_number}: {base_mission}") - results.append(base_mission) + logger.info(f"Starting Planning Phase for PR #{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" + f"Original Mission details:\n{base_mission}\n\n" + f"CRITICAL RULES:\n" + f"1. You are ONLY generating an implementation plan. DO NOT write files, DO NOT edit files, DO NOT commit, DO NOT push, and DO NOT create branches or PRs.\n" + f"2. RESEARCH FIRST (mandatory before writing the plan):\n" + f" - Use web_search to find relevant documentation, known solutions, library APIs, error explanations, and best practices.\n" + f" - Use fetch_url to read specific documentation pages, changelogs, or Stack Overflow answers in full.\n" + f"3. EXPLORE the codebase using read_file, list_files, grep_search, or run_command.\n" + f"4. Output your final plan clearly.\n" + ) + planning_agent = CodingAgent(self._model_name) + plan = await planning_agent.run_with_tools(planning_mission, planning_tools) + + logger.info(f"Starting Coding Phase for PR #{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" + f"--- PLAN ---\n{plan}\n--- PLAN END ---\n\n" + f"Original Mission details:\n{base_mission}\n\n" + 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, coding_tools_list) + results.append(response) break - # Step 1: Planning Phase - logger.info(f"Starting Planning Phase for {item.task_type} #{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" - f"Original Mission details:\n{base_mission}\n\n" - f"CRITICAL RULES:\n" - f"1. You are ONLY generating an implementation plan. DO NOT write files, DO NOT edit files, DO NOT commit, DO NOT push, and DO NOT create branches or PRs.\n" - f"2. RESEARCH FIRST (mandatory before writing the plan):\n" - f" - Use web_search to find relevant documentation, known solutions, library APIs, error explanations, and best practices.\n" - f" - Use fetch_url to read specific documentation pages, changelogs, or Stack Overflow answers in full.\n" - f" - Use web_search with time_range='month' or time_range='year' for recency-sensitive queries.\n" - f" - Use web_search with categories='it' for technical/programming topics.\n" - f"3. EXPLORE the codebase using read_file, list_files, grep_search, or run_command (read-only queries like find/grep).\n" - f"4. Output your final plan clearly, describing the exact changes to be made and which files to modify.\n" - ) - planning_agent = CodingAgent(self._model_name) - plan = await planning_agent.run_with_tools(planning_mission, planning_tools) - logger.info(f"Generated Plan:\n{plan}") + else: + # Handle Issue Task (Redesigned Planning/Question Board Workflow) + issue_info = item.task_info + assert isinstance(issue_info, IssueModel) + title = issue_info.title + issue_body = issue_info.body or "No description provided." + issue_user = issue_info.user.login if issue_info.user else "unknown" + + # Format comments and reviews for the prompt + 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." + try: + reviews = self._client.get_pr_reviews(owner, 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: + reviews_str = "No reviews available." + pr_info_str = ( + f"PR Number: #{existing_pr.number}\n" + f"PR Title: {existing_pr.title}\n" + f"PR Branch: {existing_pr.head.get('ref', 'unknown')}\n" + f"PR State: {existing_pr.state}\n" + f"PR Comments:\n{pr_comments_str}\n" + f"PR Reviews:\n{reviews_str}" + ) + + STATE_ANALYSIS_SYSTEM_PROMPT = ( + "You are an AI Coordinator. Your job is to analyze Gitea issues and pull requests, " + "read the conversation history, and determine the next action for the agent.\n\n" + "You must choose one of the following actions:\n" + "1. `PROPOSE_PLAN`: Choose this if code changes are needed to resolve the issue, and either:\n" + " - No plan has been proposed yet by the AI agent.\n" + " - Or a plan was proposed, but the human replied with feedback, corrections, or requests for changes, so we need to propose a revised plan.\n" + " You will write a detailed, step-by-step implementation plan (listing files to modify/create, specific changes to make, verification/test commands).\n" + " Your output must include a comment to post, starting with the plan and ending with a question asking if the plan is OK or if they have comments.\n" + " CRITICAL: The comment must contain the tags `` and `` on separate lines at the very end of the comment.\n\n" + "2. `ANSWER_QUESTION`: Choose this if the issue is just a question or request for information (no code changes needed), and either:\n" + " - No answer has been provided yet by the AI agent.\n" + " - Or the agent answered, but the human replied with follow-up questions or clarifications.\n" + " You will formulate a clear, helpful answer to the question.\n" + " Your output must include a comment to post, starting with the answer and ending with a question asking if this was a good enough answer.\n" + " CRITICAL: The comment must contain the tags `` and `` on separate lines at the very end of the comment.\n\n" + "3. `EXECUTE_PLAN`: Choose this if:\n" + " - A plan was previously proposed (check the comment history) AND the human has clearly replied with approval/greenlight/go-ahead (e.g. 'yes', 'looks good', 'ok', 'go ahead', etc.).\n" + " - OR there is an existing WIP PR or a PR with requested changes, and we need to continue/resume implementing the changes.\n" + " You will extract or summarize the approved plan, incorporating any feedback the human gave in their approval/reviews.\n\n" + "4. `CLOSE_ISSUE`: Choose this if the AI agent previously answered a question (using ``) and the human has replied confirming they are satisfied or giving approval to close (e.g., 'yes', 'looks good', 'thanks', 'close it', etc.).\n" + " You will write a polite final comment to post on the issue.\n\n" + "5. `NO_ACTION`: Choose this if the issue/PR is already resolved, or if we cannot proceed for another reason.\n\n" + "You MUST respond ONLY with a JSON object inside a ```json markdown code block. Do not include other text.\n" + "Example:\n" + "```json\n" + "{\n" + " \"action\": \"PROPOSE_PLAN\",\n" + " \"reasoning\": \"No plan has been proposed yet. We need to implement ...\",\n" + " \"comment_body\": \"### Proposed Implementation Plan\\n1. Modify X\\n2. Run Y\\n\\nIs this ok for implementation?\\n\\n\",\n" + " \"approved_plan\": \"\"\n" + "}\n" + "```" + ) + + state_analysis_mission = ( + f"Analyzing issue #{item.task_number} in '{repo}'.\n\n" + f"Issue Title: {title}\n" + f"Description:\n{issue_body}\n\n" + f"Issue Comments:\n{issue_comments_str}\n\n" + f"Existing PR Details:\n{pr_info_str}\n" + ) + + logger.info(f"Analyzing conversation state for issue #{item.task_number}...") + planning_agent = CodingAgent(self._model_name) + planning_agent.system_prompt = STATE_ANALYSIS_SYSTEM_PROMPT + plan_response = await planning_agent.run_with_tools(state_analysis_mission, planning_tools) + logger.info(f"State analyzer returned: {plan_response}") + + import json + decision = {} + json_match = re.search(r"```json\s*(.*?)\s*```", plan_response, re.DOTALL) + if json_match: + json_str = json_match.group(1).strip() + else: + json_str = plan_response.strip() + + try: + decision = json.loads(json_str) + except Exception as e: + logger.error(f"Failed to parse planning agent decision JSON: {e}. Attempting manual extraction.") + try: + start_idx = json_str.find('{') + end_idx = json_str.rfind('}') + if start_idx != -1 and end_idx != -1: + decision = json.loads(json_str[start_idx:end_idx+1]) + except Exception: + pass + + if not decision or "action" not in decision: + logger.info("Fallback: assuming PROPOSE_PLAN and using raw plan_response") + decision = { + "action": "PROPOSE_PLAN", + "comment_body": f"### Proposed Implementation Plan\n\n{plan_response}\n\nIs this plan ok for implementation or do you have any comments/changes?\n\n", + "approved_plan": "" + } + + action = decision.get("action", "NO_ACTION") + reasoning = decision.get("reasoning", "") + logger.info(f"Decided Action: {action}. Reasoning: {reasoning}") + + if action in ("PROPOSE_PLAN", "ANSWER_QUESTION"): + comment_body = decision.get("comment_body", "") + if not comment_body: + comment_body = decision.get("reasoning", "No details provided.") + self._client.add_comment(owner, repo_name, item.task_number, comment_body) + results.append(f"POSTED_COMMENT: {action} comment posted to issue #{item.task_number}.") + break + + elif action == "CLOSE_ISSUE": + comment_body = decision.get("comment_body", "Closing the issue as resolved.") + self._client.add_comment(owner, repo_name, item.task_number, comment_body) + self._client.close_issue(owner, repo_name, item.task_number) + results.append(f"CLOSED_ISSUE: Issue #{item.task_number} closed.") + break + + elif action == "NO_ACTION": + results.append(f"NO_ACTION: No action taken on issue #{item.task_number}.") + break + + elif action == "EXECUTE_PLAN": + pr_to_use = existing_pr + branch_name = "" + + 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}'") + else: + logger.info(f"Creating new WIP PR for issue #{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: + desc_suffix = "fix-issue" + branch_name = f"fix/issue-{item.task_number}-{desc_suffix}" + + try: + import subprocess + # Clean branch if exists, create fresh from master, and commit empty to push + subprocess.run(["git", "checkout", "master"], cwd=str(repo_path), check=True) + subprocess.run(["git", "pull", "origin", "master"], cwd=str(repo_path), check=True) + subprocess.run(["git", "branch", "-D", branch_name], cwd=str(repo_path), stderr=subprocess.DEVNULL) + subprocess.run(["git", "checkout", "-b", branch_name], cwd=str(repo_path), check=True) + subprocess.run(["git", "commit", "--allow-empty", "-m", f"WIP: start implementation for issue #{item.task_number}"], cwd=str(repo_path), check=True) + subprocess.run(["git", "push", "origin", branch_name], cwd=str(repo_path), check=True) + + # Create PR via Gitea client + pr_title = f"WIP: {title}" + pr_description = f"Work in progress for issue #{item.task_number}." + pr_to_use = self._client.create_pull_request( + owner, repo_name, head=branch_name, base="master", title=pr_title, description=pr_description + ) + + # Comment on the issue + pr_link = pr_to_use.html_url or f"{self._client.base_url}/{repo}/pulls/{pr_to_use.number}" + start_comment = f"Started work on PR #{pr_to_use.number} ({pr_link})." + self._client.add_comment(owner, repo_name, 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 #{item.task_number}: {e}") + results.append(f"FAILED to create WIP PR: {e}") + break + + # Now run the Coding Phase on the PR branch + base_mission = self._build_issue_mission(item) + coding_mission = ( + f"PHASE 2: EXECUTION/CODING PHASE\n\n" + f"You are implementing changes for issue #{item.task_number} in repository '{repo}'.\n" + f"You are working on the existing Pull Request #{pr_to_use.number} on branch '{branch_name}'.\n\n" + f"--- APPROVED PLAN ---\n{decision.get('approved_plan', '')}\n--- APPROVED PLAN END ---\n\n" + f"Original Mission details:\n{base_mission}\n\n" + f"DIRECTIONS:\n" + f"1. Checkout the branch '{branch_name}' (it should already be checked out, or run `git checkout {branch_name}`).\n" + f"2. Implement the changes according to the APPROVED PLAN.\n" + f"3. Run verification/tests (check AGENTS.md for conventions).\n" + f"4. Commit and push your changes to origin on the branch '{branch_name}'.\n" + f"5. ONCE COMPLETED SUCCESSFULLY:\n" + f" - Call `update_pull_request(owner='{owner}', repo='{repo_name}', pull_number={pr_to_use.number}, title='{title}', body='')`.\n" + f" Note: The PR title must not contain 'WIP:'. The PR body must follow the mandatory PR template in CODING AGENT SYSTEM PROMPT.\n" + f" - Call `add_comment_to_issue(owner='{owner}', repo='{repo_name}', issue_number={item.task_number}, body='Work has been completed in PR #{pr_to_use.number}.')`.\n" + f"6. IF YOU ENCOUNTER A BLOCKER OR FAIL:\n" + f" - You are allowed (and encouraged) to comment on the WIP PR #{pr_to_use.number} (using `add_comment`) with any details, logs, or context to help the next agent resume the work.\n\n" + f"AVAILABLE RESEARCH TOOLS:\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" + ) + logger.info(f"Starting Execution/Coding Phase for issue #{item.task_number} on branch '{branch_name}'") + coding_agent = CodingAgent(self._model_name) + response = await coding_agent.run_with_tools(coding_mission, coding_tools_list) + logger.info(f"Agent response for issue #{item.task_number}: {response}") + results.append(response) + break - # Step 2: Coding Phase - logger.info(f"Starting Execution/Coding Phase for {item.task_type} #{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 generated in Phase 1:\n" - f"--- PLAN ---\n{plan}\n--- PLAN END ---\n\n" - f"Original Mission details:\n{base_mission}\n\n" - f"AVAILABLE RESEARCH TOOLS (use whenever uncertain about an API, syntax, or behaviour):\n" - f" - web_search(query, time_range, categories) — search the web via SearXNG/DuckDuckGo\n" - f" - fetch_url(url) — read any documentation page, README, or Stack Overflow answer in full\n\n" - f"Follow the repository workflow: implement changes, run verification, commit, push, and create a PR.\n" - ) - coding_agent = CodingAgent(self._model_name) - response = await coding_agent.run_with_tools(coding_mission, coding_tools_list) - logger.info(f"Agent response for {item.task_type} #{item.task_number}: {response}") - results.append(response) - break except Exception as e: - logger.error(f"Error processing {item.task_type} #{item.task_number} (attempt {attempt}/{self._max_retries}): {e}") + logger.error(f"Error processing issue/PR #{item.task_number} (attempt {attempt}/{self._max_retries}): {e}") if attempt == self._max_retries: results.append(f"FAILED after {self._max_retries} attempts: {str(e)}") finally: diff --git a/core/interfaces.py b/core/interfaces.py index 24601c9..d6b5586 100644 --- a/core/interfaces.py +++ b/core/interfaces.py @@ -84,6 +84,17 @@ class PullRequestsClient(ABC): self, owner: str, repo: str, head: str, base: str, title: str, description: str = "" ) -> PullRequestModel: ... + @abstractmethod + def update_pull_request( + self, + owner: str, + repo: str, + pull_number: int, + title: str | None = None, + body: str | None = None, + state: str | None = None, + ) -> PullRequestModel: ... + @abstractmethod def approve_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]: ... diff --git a/gitea/client.py b/gitea/client.py index 2b771fe..8fb54ba 100644 --- a/gitea/client.py +++ b/gitea/client.py @@ -237,6 +237,32 @@ class GiteaClient(IssuesClient, PullRequestsClient, FilesClient, RefsClient, Rep print(f"Error creating pull request: {e}") raise + def update_pull_request( + self, + owner: str, + repo: str, + pull_number: int, + title: str | None = None, + body: str | None = None, + state: str | None = None, + ) -> PullRequestModel: + try: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}" + data: dict[str, Any] = {} + if title is not None: + data["title"] = title + if body is not None: + data["body"] = body + if state is not None: + data["state"] = state + response = client.patch(url, headers=self.headers, json=data) + response.raise_for_status() + return PullRequestModel(**response.json()) + except Exception as e: + print(f"Error updating pull request: {e}") + raise + def create_pr_via_tea( self, owner: str, repo: str, title: str, description: str, head: str, base: str ) -> PullRequestModel: diff --git a/gitea/models.py b/gitea/models.py index c96188b..ac40e42 100644 --- a/gitea/models.py +++ b/gitea/models.py @@ -80,6 +80,7 @@ class PullRequestModel(BaseModel): patch_url: Optional[str] = None html_url: Optional[str] = None merged: bool = False + requested_reviewers: list[UserModel] = Field(default_factory=list) class CommentModel(BaseModel): diff --git a/gitea/tools/gitea_tools.py b/gitea/tools/gitea_tools.py index ae7cb06..d66b895 100644 --- a/gitea/tools/gitea_tools.py +++ b/gitea/tools/gitea_tools.py @@ -86,6 +86,18 @@ class GiteaTools: """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) diff --git a/gitea/tools/pr_tools.py b/gitea/tools/pr_tools.py index 820c5c0..b2b473e 100644 --- a/gitea/tools/pr_tools.py +++ b/gitea/tools/pr_tools.py @@ -113,6 +113,21 @@ class PRTools: except Exception as e: return f"Error creating PR: {str(e)}" + def update_pull_request( + self, + owner: str, + repo: str, + pull_number: int, + title: str | None = None, + body: str | None = None, + state: str | None = None, + ) -> str: + try: + pr = self._client.update_pull_request(owner, repo, pull_number, title, body, state) + return pr.model_dump_json(indent=2) + except Exception as e: + return f"Error updating PR #{pull_number}: {str(e)}" + def add_label_to_pr(self, owner: str, repo: str, pr_number: int, label: str) -> str: try: self._client.add_label_pr(owner, repo, pr_number, label) diff --git a/tests/test_best_practices.py b/tests/test_best_practices.py index 5e617a2..071d536 100644 --- a/tests/test_best_practices.py +++ b/tests/test_best_practices.py @@ -10,7 +10,7 @@ from core.dispatcher import AgentDispatcher from core.queue import WorkItem from gitea.client import GiteaClient from gitea.tools.gitea_tools import GiteaTools -from gitea.models import IssueModel +from gitea.models import IssueModel, PullRequestModel pytestmark = pytest.mark.anyio @@ -102,6 +102,21 @@ async def test_dispatch_planning_and_coding_phases(mock_agent_class: MagicMock) mock_client.list_repo_pull_requests.return_value = [] mock_client.get_issue_comments.return_value = [] + from gitea.models import UserModel + mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai") + + mock_pr = PullRequestModel( + number=42, + title="fix bug", + body="bug details", + user=UserModel(login="meeks-ai") + ) + mock_client.get_pull_request.return_value = mock_pr + mock_client.get_pull_request_diff.return_value = "diff" + mock_client.get_pull_request_comments.return_value = [] + mock_client.get_pull_request_files.return_value = [] + mock_client.get_pr_reviews.return_value = [] + # Mock CodingAgent instances mock_planning_agent = MagicMock() mock_planning_agent.run_with_tools = AsyncMock(return_value="Plan: Modify file A") @@ -115,9 +130,9 @@ async def test_dispatch_planning_and_coding_phases(mock_agent_class: MagicMock) work_item = WorkItem( repo_full_name="meeks/repo1", - task_type="issue", + task_type="pr", task_number=42, - task_info=IssueModel(number=42, title="fix bug", body="bug details"), + task_info=mock_pr, priority=0 ) diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index 3938b9f..0f48770 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -1,10 +1,10 @@ import pytest -from unittest.mock import MagicMock, AsyncMock, patch +from unittest.mock import MagicMock, AsyncMock, patch, ANY from core.dispatcher import AgentDispatcher from core.queue import WorkItem from gitea.client import GiteaClient from gitea.tools.gitea_tools import GiteaTools -from gitea.models import PullRequestModel, IssueModel, CommentModel +from gitea.models import PullRequestModel, IssueModel, CommentModel, UserModel pytestmark = pytest.mark.anyio @@ -54,7 +54,14 @@ async def test_dispatch_processes_issue_without_pr(mock_agent_class: MagicMock) # Mock CodingAgent run_with_tools mock_agent_instance = MagicMock() - mock_agent_instance.run_with_tools = AsyncMock(return_value="Issue resolved.") + mock_agent_instance.run_with_tools = AsyncMock(return_value="""```json +{ + "action": "PROPOSE_PLAN", + "reasoning": "Plan needs to be proposed first.", + "comment_body": "### Proposed Plan\\n- change X", + "approved_plan": "" +} +```""") mock_agent_class.return_value = mock_agent_instance dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) @@ -70,7 +77,7 @@ async def test_dispatch_processes_issue_without_pr(mock_agent_class: MagicMock) results = await dispatcher.dispatch("meeks/repo1", [work_item]) assert len(results) == 1 - assert results[0] == "Issue resolved." + assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0] @patch("core.dispatcher.CodingAgent") @@ -181,10 +188,13 @@ async def test_dispatch_skips_already_reviewed_pr() -> None: mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_tools: MagicMock = MagicMock(spec=GiteaTools) + from gitea.models import UserModel + mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai") pr = PullRequestModel( number=104, title="already reviewed PR", - body="closes #42" + body="closes #42", + user=UserModel(login="meeks-ai") ) mock_client.get_pull_request.return_value = pr mock_client.get_pull_request_diff.return_value = "diff" @@ -248,3 +258,256 @@ def test_is_awaiting_reply_no_marker_not_detected() -> None: dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock()) comments = [_make_comment("meeks-ai", "Should I use approach A or B?")] assert dispatcher._is_awaiting_reply(comments) is False + + +@patch("core.dispatcher.CodingAgent") +async def test_dispatch_proposes_plan(mock_agent_class: MagicMock) -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_tools: MagicMock = MagicMock(spec=GiteaTools) + + mock_client.list_repo_pull_requests.return_value = [] + mock_client.get_issue_comments.return_value = [] + mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai") + + mock_agent_instance = MagicMock() + mock_agent_instance.run_with_tools = AsyncMock(return_value="""```json +{ + "action": "PROPOSE_PLAN", + "reasoning": "We need to add a new endpoint.", + "comment_body": "### Proposed Plan\\n- Add endpoint\\n\\n" +} +```""") + mock_agent_class.return_value = mock_agent_instance + + dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) + work_item = WorkItem( + repo_full_name="meeks/repo1", + task_type="issue", + task_number=42, + task_info=IssueModel(number=42, title="add X", body=""), + priority=0 + ) + + results = await dispatcher.dispatch("meeks/repo1", [work_item]) + assert len(results) == 1 + assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0] + mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "### Proposed Plan\n- Add endpoint\n\n") + + +@patch("core.dispatcher.CodingAgent") +async def test_dispatch_answers_question(mock_agent_class: MagicMock) -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_tools: MagicMock = MagicMock(spec=GiteaTools) + + mock_client.list_repo_pull_requests.return_value = [] + mock_client.get_issue_comments.return_value = [] + mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai") + + mock_agent_instance = MagicMock() + mock_agent_instance.run_with_tools = AsyncMock(return_value="""```json +{ + "action": "ANSWER_QUESTION", + "reasoning": "This is a question about how X works.", + "comment_body": "X works by doing Y.\\n\\n" +} +```""") + mock_agent_class.return_value = mock_agent_instance + + dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) + work_item = WorkItem( + repo_full_name="meeks/repo1", + task_type="issue", + task_number=42, + task_info=IssueModel(number=42, title="how does X work", body=""), + priority=0 + ) + + results = await dispatcher.dispatch("meeks/repo1", [work_item]) + assert len(results) == 1 + assert "POSTED_COMMENT: ANSWER_QUESTION" in results[0] + mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "X works by doing Y.\n\n") + + +@patch("core.dispatcher.CodingAgent") +async def test_dispatch_closes_issue_on_satisfaction(mock_agent_class: MagicMock) -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_tools: MagicMock = MagicMock(spec=GiteaTools) + + mock_client.list_repo_pull_requests.return_value = [] + mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai") + + # Human comments indicating satisfaction after our answer + mock_client.get_issue_comments.return_value = [ + _make_comment("meeks-ai", "Here is the answer.\n\n"), + _make_comment("michael", "Yes, thanks! That makes sense.") + ] + + mock_agent_instance = MagicMock() + mock_agent_instance.run_with_tools = AsyncMock(return_value="""```json +{ + "action": "CLOSE_ISSUE", + "reasoning": "User is satisfied.", + "comment_body": "Closing the issue now. Let me know if you need anything else!" +} +```""") + mock_agent_class.return_value = mock_agent_instance + + dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) + work_item = WorkItem( + repo_full_name="meeks/repo1", + task_type="issue", + task_number=42, + task_info=IssueModel(number=42, title="how does X work", body=""), + priority=0 + ) + + results = await dispatcher.dispatch("meeks/repo1", [work_item]) + assert len(results) == 1 + assert "CLOSED_ISSUE: Issue #42 closed." in results[0] + mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "Closing the issue now. Let me know if you need anything else!") + mock_client.close_issue.assert_called_once_with("meeks", "repo1", 42) + + +@patch("subprocess.run") +@patch("core.dispatcher.CodingAgent") +async def test_dispatch_executes_approved_plan_and_creates_wip_pr(mock_agent_class: MagicMock, mock_run: MagicMock) -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_tools: MagicMock = MagicMock(spec=GiteaTools) + + mock_client.list_repo_pull_requests.return_value = [] + mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai") + mock_client.get_issue_comments.return_value = [ + _make_comment("meeks-ai", "### Proposed Plan\n\n"), + _make_comment("michael", "looks good, go ahead") + ] + + # Return PR object on creation + mock_pr = PullRequestModel(number=105, title="WIP: add X", html_url="http://gitea/pr/105", head={"ref": "fix/issue-42-add-x"}) + mock_client.create_pull_request.return_value = mock_pr + + # Mock planning agent deciding EXECUTE_PLAN + mock_agent_instance1 = MagicMock() + mock_agent_instance1.run_with_tools = AsyncMock(return_value="""```json +{ + "action": "EXECUTE_PLAN", + "reasoning": "Plan was approved.", + "approved_plan": "Step 1. Code X" +} +```""") + + # Mock coding agent executing plan + mock_agent_instance2 = MagicMock() + mock_agent_instance2.run_with_tools = AsyncMock(return_value="PR Completed Successfully.") + + mock_agent_class.side_effect = [mock_agent_instance1, mock_agent_instance2] + + dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) + work_item = WorkItem( + repo_full_name="meeks/repo1", + task_type="issue", + task_number=42, + task_info=IssueModel(number=42, title="add X", body=""), + priority=0 + ) + + results = await dispatcher.dispatch("meeks/repo1", [work_item]) + assert len(results) == 1 + assert results[0] == "PR Completed Successfully." + + # Verify subprocess git commands + mock_run.assert_any_call(["git", "checkout", "master"], cwd=ANY, check=True) + mock_run.assert_any_call(["git", "checkout", "-b", "fix/issue-42-add-x"], cwd=ANY, check=True) + + # Verify WIP PR creation and starting comment + mock_client.create_pull_request.assert_called_once_with( + "meeks", "repo1", head="fix/issue-42-add-x", base="master", title="WIP: add X", description="Work in progress for issue #42." + ) + mock_client.add_comment.assert_any_call("meeks", "repo1", 42, "Started work on PR #105 (http://gitea/pr/105).") + + +@patch("subprocess.run") +@patch("core.dispatcher.CodingAgent") +async def test_dispatch_resumes_wip_pr(mock_agent_class: MagicMock, mock_run: MagicMock) -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_tools: MagicMock = MagicMock(spec=GiteaTools) + + mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai") + + # Existing WIP PR addressing issue #42 + wip_pr = PullRequestModel( + number=105, + title="WIP: add X", + state="open", + head={"ref": "fix/issue-42-add-x"} + ) + mock_client.list_repo_pull_requests.return_value = [wip_pr] + mock_client.get_pr_reviews.return_value = [] + + mock_client.get_issue_comments.return_value = [ + _make_comment("meeks-ai", "### Proposed Plan\n\n"), + _make_comment("michael", "looks good, go ahead") + ] + mock_client.get_pull_request_comments.return_value = [] + + # Mock planning agent deciding EXECUTE_PLAN + mock_agent_instance1 = MagicMock() + mock_agent_instance1.run_with_tools = AsyncMock(return_value="""```json +{ + "action": "EXECUTE_PLAN", + "reasoning": "WIP PR exists, resume coding.", + "approved_plan": "Step 1. Resume coding" +} +```""") + + # Mock coding agent executing plan + mock_agent_instance2 = MagicMock() + mock_agent_instance2.run_with_tools = AsyncMock(return_value="PR Updated Successfully.") + + mock_agent_class.side_effect = [mock_agent_instance1, mock_agent_instance2] + + dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) + work_item = WorkItem( + repo_full_name="meeks/repo1", + task_type="issue", + task_number=42, + task_info=IssueModel(number=42, title="add X", body=""), + priority=0 + ) + + results = await dispatcher.dispatch("meeks/repo1", [work_item]) + assert len(results) == 1 + assert results[0] == "PR Updated Successfully." + + # Ensure create_pull_request was NOT called since it already exists + mock_client.create_pull_request.assert_not_called() + + +async def test_dispatch_skips_pr_if_not_requested_reviewer() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_tools: MagicMock = MagicMock(spec=GiteaTools) + + mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai") + + # PR authored by michael, requested reviewers is empty (agent not requested) + pr_detail = PullRequestModel( + number=201, + title="some feature", + state="open", + user=UserModel(login="michael"), + requested_reviewers=[] + ) + mock_client.get_pull_request.return_value = pr_detail + + dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) + work_item = WorkItem( + repo_full_name="meeks/repo1", + task_type="pr", + task_number=201, + task_info=PullRequestModel(number=201, title="some feature"), + priority=0 + ) + + results = await dispatcher.dispatch("meeks/repo1", [work_item]) + assert len(results) == 1 + assert "SKIP: Agent is not a requested reviewer" in results[0] + diff --git a/tests/test_file_tools.py b/tests/test_file_tools.py index 0b0cdf3..2f7809d 100644 --- a/tests/test_file_tools.py +++ b/tests/test_file_tools.py @@ -9,7 +9,7 @@ def test_get_file_content_string_success() -> None: file_tools: FileTools = FileTools(mock_client) res: str = file_tools.get_file_content("owner", "repo", "path/to/file") - assert res == "file content here" + assert res == "1: file content here" mock_client.get_file_content.assert_called_once_with("owner", "repo", "path/to/file") @@ -19,7 +19,7 @@ def test_get_file_content_list_success() -> None: file_tools: FileTools = FileTools(mock_client) res: str = file_tools.get_file_content("owner", "repo", "path/to/file") - assert res == "line1\nline2" + assert res == "1: line1\n2: line2" def test_get_file_content_failure() -> None: @@ -37,7 +37,7 @@ def test_get_file_content_with_ref_string_success() -> None: file_tools: FileTools = FileTools(mock_client) res: str = file_tools.get_file_content_with_ref("owner", "repo", "path/to/file", "main") - assert res == "file content here" + assert res == "1: file content here" mock_client.get_file_content.assert_called_once_with("owner", "repo", "path/to/file", "main") @@ -47,7 +47,7 @@ def test_get_file_content_with_ref_list_success() -> None: file_tools: FileTools = FileTools(mock_client) res: str = file_tools.get_file_content_with_ref("owner", "repo", "path/to/file", "main") - assert res == "line1\nline2" + assert res == "1: line1\n2: line2" def test_get_file_content_with_ref_failure() -> None: