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:
@@ -50,10 +50,10 @@ Every change MUST follow this exact workflow:
|
|||||||
git push origin feat/descriptive-name
|
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).
|
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.
|
Call the tools directly.
|
||||||
where the PR description follows the template below.
|
The PR description/body MUST follow the template below.
|
||||||
|
|
||||||
### 📋 PR TEMPLATE (MANDATORY)
|
### 📋 PR TEMPLATE (MANDATORY)
|
||||||
Every PR body MUST use this exact template:
|
Every PR body MUST use this exact template:
|
||||||
|
|||||||
+273
-26
@@ -127,6 +127,7 @@ class AgentDispatcher:
|
|||||||
self._tools.list_pull_requests,
|
self._tools.list_pull_requests,
|
||||||
self._tools.get_file_content,
|
self._tools.get_file_content,
|
||||||
self._tools.create_pull_request,
|
self._tools.create_pull_request,
|
||||||
|
self._tools.update_pull_request,
|
||||||
self._tools.add_label_to_issue,
|
self._tools.add_label_to_issue,
|
||||||
self._tools.add_label_to_pr,
|
self._tools.add_label_to_pr,
|
||||||
self._tools.create_branch,
|
self._tools.create_branch,
|
||||||
@@ -164,29 +165,76 @@ class AgentDispatcher:
|
|||||||
os.chdir(str(repo_path))
|
os.chdir(str(repo_path))
|
||||||
changed_dir = True
|
changed_dir = True
|
||||||
try:
|
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:
|
for item in work_items:
|
||||||
|
owner, repo_name = repo.split("/")
|
||||||
|
|
||||||
if item.task_type == "issue":
|
if item.task_type == "issue":
|
||||||
existing_pr = self._find_pr_for_issue(repo, item.task_number)
|
existing_pr = self._find_pr_for_issue(repo, item.task_number)
|
||||||
|
is_wip = False
|
||||||
|
has_request_changes = False
|
||||||
|
|
||||||
if existing_pr:
|
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.")
|
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.")
|
results.append(f"SKIP: A pull request (PR #{existing_pr.number}) addressing issue #{item.task_number} already exists.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if we're waiting for a human reply before acting
|
# Check comments on issue and PR
|
||||||
owner, repo_name = repo.split("/")
|
|
||||||
issue_comments = []
|
issue_comments = []
|
||||||
try:
|
try:
|
||||||
issue_comments = self._client.get_issue_comments(owner, repo_name, item.task_number)
|
issue_comments = self._client.get_issue_comments(owner, repo_name, item.task_number)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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.")
|
pr_comments = []
|
||||||
results.append(f"SKIP: Awaiting human reply on issue #{item.task_number}.")
|
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
|
continue
|
||||||
|
|
||||||
elif item.task_type == "pr":
|
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
|
# Check if we're waiting for a human reply before acting on a PR
|
||||||
owner, repo_name = repo.split("/")
|
|
||||||
pr_comments = []
|
pr_comments = []
|
||||||
try:
|
try:
|
||||||
pr_comments = self._client.get_pull_request_comments(owner, repo_name, item.task_number)
|
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):
|
for attempt in range(1, self._max_retries + 1):
|
||||||
try:
|
try:
|
||||||
if item.task_type == "issue":
|
if item.task_type == "pr":
|
||||||
base_mission = self._build_issue_mission(item)
|
# Process PR as before
|
||||||
else:
|
|
||||||
base_mission = self._build_pr_mission(item)
|
base_mission = self._build_pr_mission(item)
|
||||||
|
|
||||||
if base_mission.startswith("SKIP:"):
|
if base_mission.startswith("SKIP:"):
|
||||||
logger.info(f"Skipping task #{item.task_number}: {base_mission}")
|
logger.info(f"Skipping task #{item.task_number}: {base_mission}")
|
||||||
results.append(base_mission)
|
results.append(base_mission)
|
||||||
break
|
break
|
||||||
|
|
||||||
# Step 1: Planning Phase
|
logger.info(f"Starting Planning Phase for PR #{item.task_number} (attempt {attempt})")
|
||||||
logger.info(f"Starting Planning Phase for {item.task_type} #{item.task_number} (attempt {attempt})")
|
|
||||||
planning_mission = (
|
planning_mission = (
|
||||||
f"PHASE 1: PLANNING PHASE\n\n"
|
f"PHASE 1: PLANNING PHASE\n\n"
|
||||||
f"Your task is to research the problem, analyse the repository structure, and produce a detailed implementation plan.\n"
|
f"Your task is to research the problem, analyse the repository structure, and produce a detailed implementation plan.\n"
|
||||||
@@ -220,34 +265,236 @@ class AgentDispatcher:
|
|||||||
f"2. RESEARCH FIRST (mandatory before writing the plan):\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 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 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"3. EXPLORE the codebase using read_file, list_files, grep_search, or run_command.\n"
|
||||||
f" - Use web_search with categories='it' for technical/programming topics.\n"
|
f"4. Output your final plan clearly.\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)
|
planning_agent = CodingAgent(self._model_name)
|
||||||
plan = await planning_agent.run_with_tools(planning_mission, planning_tools)
|
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 Coding Phase for PR #{item.task_number} (attempt {attempt})")
|
||||||
logger.info(f"Starting Execution/Coding Phase for {item.task_type} #{item.task_number} (attempt {attempt})")
|
|
||||||
coding_mission = (
|
coding_mission = (
|
||||||
f"PHASE 2: EXECUTION/CODING PHASE\n\n"
|
f"PHASE 2: EXECUTION/CODING PHASE\n\n"
|
||||||
f"You must now implement the changes based on the following plan 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"--- PLAN ---\n{plan}\n--- PLAN END ---\n\n"
|
||||||
f"Original Mission details:\n{base_mission}\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"Follow the workflow to implement changes, verify, and complete PR review/updates.\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)
|
coding_agent = CodingAgent(self._model_name)
|
||||||
response = await coding_agent.run_with_tools(coding_mission, coding_tools_list)
|
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)
|
results.append(response)
|
||||||
break
|
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:
|
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:
|
if attempt == self._max_retries:
|
||||||
results.append(f"FAILED after {self._max_retries} attempts: {str(e)}")
|
results.append(f"FAILED after {self._max_retries} attempts: {str(e)}")
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -84,6 +84,17 @@ class PullRequestsClient(ABC):
|
|||||||
self, owner: str, repo: str, head: str, base: str, title: str, description: str = ""
|
self, owner: str, repo: str, head: str, base: str, title: str, description: str = ""
|
||||||
) -> PullRequestModel: ...
|
) -> 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
|
@abstractmethod
|
||||||
def approve_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]: ...
|
def approve_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,32 @@ class GiteaClient(IssuesClient, PullRequestsClient, FilesClient, RefsClient, Rep
|
|||||||
print(f"Error creating pull request: {e}")
|
print(f"Error creating pull request: {e}")
|
||||||
raise
|
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(
|
def create_pr_via_tea(
|
||||||
self, owner: str, repo: str, title: str, description: str, head: str, base: str
|
self, owner: str, repo: str, title: str, description: str, head: str, base: str
|
||||||
) -> PullRequestModel:
|
) -> PullRequestModel:
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ class PullRequestModel(BaseModel):
|
|||||||
patch_url: Optional[str] = None
|
patch_url: Optional[str] = None
|
||||||
html_url: Optional[str] = None
|
html_url: Optional[str] = None
|
||||||
merged: bool = False
|
merged: bool = False
|
||||||
|
requested_reviewers: list[UserModel] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CommentModel(BaseModel):
|
class CommentModel(BaseModel):
|
||||||
|
|||||||
@@ -86,6 +86,18 @@ class GiteaTools:
|
|||||||
"""Create a new pull request. Args: owner, repo, head (source branch), base (target branch), title, description."""
|
"""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)
|
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:
|
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)."""
|
"""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)
|
return self.issue_tools.create_issue(owner, repo, title, body, labels, assignees)
|
||||||
|
|||||||
@@ -113,6 +113,21 @@ class PRTools:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error creating PR: {str(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:
|
def add_label_to_pr(self, owner: str, repo: str, pr_number: int, label: str) -> str:
|
||||||
try:
|
try:
|
||||||
self._client.add_label_pr(owner, repo, pr_number, label)
|
self._client.add_label_pr(owner, repo, pr_number, label)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from core.dispatcher import AgentDispatcher
|
|||||||
from core.queue import WorkItem
|
from core.queue import WorkItem
|
||||||
from gitea.client import GiteaClient
|
from gitea.client import GiteaClient
|
||||||
from gitea.tools.gitea_tools import GiteaTools
|
from gitea.tools.gitea_tools import GiteaTools
|
||||||
from gitea.models import IssueModel
|
from gitea.models import IssueModel, PullRequestModel
|
||||||
|
|
||||||
pytestmark = pytest.mark.anyio
|
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.list_repo_pull_requests.return_value = []
|
||||||
mock_client.get_issue_comments.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 CodingAgent instances
|
||||||
mock_planning_agent = MagicMock()
|
mock_planning_agent = MagicMock()
|
||||||
mock_planning_agent.run_with_tools = AsyncMock(return_value="Plan: Modify file A")
|
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(
|
work_item = WorkItem(
|
||||||
repo_full_name="meeks/repo1",
|
repo_full_name="meeks/repo1",
|
||||||
task_type="issue",
|
task_type="pr",
|
||||||
task_number=42,
|
task_number=42,
|
||||||
task_info=IssueModel(number=42, title="fix bug", body="bug details"),
|
task_info=mock_pr,
|
||||||
priority=0
|
priority=0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+268
-5
@@ -1,10 +1,10 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import MagicMock, AsyncMock, patch
|
from unittest.mock import MagicMock, AsyncMock, patch, ANY
|
||||||
from core.dispatcher import AgentDispatcher
|
from core.dispatcher import AgentDispatcher
|
||||||
from core.queue import WorkItem
|
from core.queue import WorkItem
|
||||||
from gitea.client import GiteaClient
|
from gitea.client import GiteaClient
|
||||||
from gitea.tools.gitea_tools import GiteaTools
|
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
|
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 CodingAgent run_with_tools
|
||||||
mock_agent_instance = MagicMock()
|
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
|
mock_agent_class.return_value = mock_agent_instance
|
||||||
|
|
||||||
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
|
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])
|
results = await dispatcher.dispatch("meeks/repo1", [work_item])
|
||||||
|
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert results[0] == "Issue resolved."
|
assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0]
|
||||||
|
|
||||||
|
|
||||||
@patch("core.dispatcher.CodingAgent")
|
@patch("core.dispatcher.CodingAgent")
|
||||||
@@ -181,10 +188,13 @@ async def test_dispatch_skips_already_reviewed_pr() -> None:
|
|||||||
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
mock_client: MagicMock = MagicMock(spec=GiteaClient)
|
||||||
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
|
||||||
|
|
||||||
|
from gitea.models import UserModel
|
||||||
|
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
|
||||||
pr = PullRequestModel(
|
pr = PullRequestModel(
|
||||||
number=104,
|
number=104,
|
||||||
title="already reviewed PR",
|
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.return_value = pr
|
||||||
mock_client.get_pull_request_diff.return_value = "diff"
|
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())
|
dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock())
|
||||||
comments = [_make_comment("meeks-ai", "Should I use approach A or B?")]
|
comments = [_make_comment("meeks-ai", "Should I use approach A or B?")]
|
||||||
assert dispatcher._is_awaiting_reply(comments) is False
|
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]
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ def test_get_file_content_string_success() -> None:
|
|||||||
|
|
||||||
file_tools: FileTools = FileTools(mock_client)
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
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")
|
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)
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
|
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:
|
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)
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
res: str = file_tools.get_file_content_with_ref("owner", "repo", "path/to/file", "main")
|
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")
|
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)
|
file_tools: FileTools = FileTools(mock_client)
|
||||||
res: str = file_tools.get_file_content_with_ref("owner", "repo", "path/to/file", "main")
|
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:
|
def test_get_file_content_with_ref_failure() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user