feat: redesign issue processing to planning and question board with WIP PR workflow (#3)

Co-authored-by: Michael <michael@example.com>
Reviewed-on: #3
This commit is contained in:
2026-06-29 20:35:46 +02:00
parent bf13a979c2
commit a0c247b152
10 changed files with 658 additions and 68 deletions
+3 -3
View File
@@ -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:
+300 -53
View File
@@ -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 `<!-- agent:plan-proposal -->` and `<!-- agent:awaiting-reply -->` 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 `<!-- agent:question-response -->` and `<!-- agent:awaiting-reply -->` 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 `<!-- agent:question-response -->`) 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<!-- agent:plan-proposal -->\\n<!-- agent:awaiting-reply -->\",\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<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->",
"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='<filled PR template>')`.\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:
+11
View File
@@ -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]: ...
+26
View File
@@ -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:
+1
View File
@@ -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):
+12
View File
@@ -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)
+15
View File
@@ -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)
+18 -3
View File
@@ -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
)
+268 -5
View File
@@ -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<!-- agent:plan-proposal -->\\n<!-- agent:awaiting-reply -->"
}
```""")
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<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->")
@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<!-- agent:question-response -->\\n<!-- agent:awaiting-reply -->"
}
```""")
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<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->")
@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<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->"),
_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<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
_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<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"),
_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]
+4 -4
View File
@@ -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: