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:
+273 -26
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)
is_wip = False
has_request_changes = False
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 if we're waiting for a human reply before acting
owner, repo_name = repo.split("/")
# 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,18 +247,15 @@ 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
# Step 1: Planning Phase
logger.info(f"Starting Planning Phase for {item.task_type} #{item.task_number} (attempt {attempt})")
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"
@@ -220,34 +265,236 @@ class AgentDispatcher:
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"
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"Generated Plan:\n{plan}")
# Step 2: Coding Phase
logger.info(f"Starting Execution/Coding Phase for {item.task_type} #{item.task_number} (attempt {attempt})")
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 generated in Phase 1:\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"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"
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)
logger.info(f"Agent response for {item.task_type} #{item.task_number}: {response}")
results.append(response)
break
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"Error processing {item.task_type} #{item.task_number} (attempt {attempt}/{self._max_retries}): {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
except Exception as 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: