Refactor code structure for improved readability and maintainability

This commit is contained in:
mi222eh
2026-07-06 21:37:20 +02:00
parent 6ca6a5687a
commit 5f35f56edc
48 changed files with 8128 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Core packages."""
+116
View File
@@ -0,0 +1,116 @@
import asyncio
import logging
import lmstudio as lms
from typing import Any, Callable
from core.interfaces import Agent
from .prompt import CAVEMAN_PROMPT
logger: logging.Logger = logging.getLogger("agent-base")
class _ActResponseCapture:
"""Captures the AI response from LMStudio act() callback."""
def __init__(self) -> None:
self.responses: list[str] = []
def __call__(self, message: Any) -> None:
content: str = ""
if hasattr(message, 'content'):
content = message.content
elif hasattr(message, 'text'):
content = message.text
elif hasattr(message, 'response'):
content = message.response
elif hasattr(message, 'message'):
content = message.message
else:
return
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
text: str = item.get('text', '')
if isinstance(text, list):
parts.extend([str(t) for t in text])
else:
parts.append(str(text))
elif isinstance(item, str):
parts.append(item)
elif hasattr(item, 'text'):
parts.append(str(item.text))
elif hasattr(item, 'content'):
parts.append(str(item.content))
content = ''.join(parts)
elif not isinstance(content, str):
content = str(content)
if content.strip():
self.responses.append(content.strip())
@property
def full_response(self) -> str:
return '\n'.join(self.responses) if self.responses else "No response captured."
class BaseAgent(Agent):
"""Base AI agent implementing common LMStudio interaction patterns."""
def __init__(self, model_name: str) -> None:
self.model_name: str = model_name
self.model: Any | None = None
self.system_prompt: str = ""
async def initialize(self) -> None:
"""Initialize the LM Studio model."""
logger.info(f"Initializing agent with model: {self.model_name}")
self.model = lms.llm(self.model_name)
async def run(self, user_input: str) -> str:
"""Run a single interaction with the agent."""
if self.model is None:
await self.initialize()
assert self.model is not None
messages: list[dict[str, str]] = [
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": user_input},
]
try:
logger.info(f"Running agent interactively (input length: {len(user_input)})")
response = await self.model.respond(user_input, messages=messages)
logger.info(f"Agent responded successfully (response length: {len(response)})")
return response
except Exception as e:
logger.error(f"Agent execution error: {e}")
return f"Error in agent execution: {str(e)}"
async def run_with_tools(self, user_input: str, tools: list[Any]) -> str:
"""Run the agent with tool calling capability."""
if self.model is None:
await self.initialize()
assert self.model is not None
try:
capture = _ActResponseCapture()
logger.info(f"Calling LMStudio act() on agent with {len(tools)} tools...")
result: lms.ActResult = self.model.act(user_input, tools=tools, on_message=capture)
logger.info(f"act() on agent returned: {result}")
response: str = capture.full_response
if not response or response == "No response captured.":
logger.warning(f"Act completed with {result.rounds} rounds but no response was captured.")
return f"Act completed with {result.rounds} rounds but no response captured."
return response
except Exception as e:
logger.error(f"Agent tool execution error: {e}")
return f"Error in agent tool execution: {str(e)}"
class CavemanAgent(BaseAgent):
"""Caveman AI agent - minimal token usage variant."""
def __init__(self, model_name: str) -> None:
super().__init__(model_name)
self.system_prompt = CAVEMAN_PROMPT
+13
View File
@@ -0,0 +1,13 @@
import logging
from core.agent import BaseAgent
from .coding_prompt import CODING_AGENT_SYSTEM_PROMPT
logger: logging.Logger = logging.getLogger("agent-coding")
class CodingAgent(BaseAgent):
"""AI agent that interacts with LMStudio models and tools for coding tasks."""
def __init__(self, model_name: str) -> None:
super().__init__(model_name)
self.system_prompt = CODING_AGENT_SYSTEM_PROMPT
+221
View File
@@ -0,0 +1,221 @@
CODING_AGENT_SYSTEM_PROMPT = """
CODING AGENT SYSTEM PROMPT
You are an autonomous AI Software Engineer working on the `meeks` organization's repositories. Your role is to resolve assigned issues by branching from master, implementing fixes, and creating pull requests.
### 🎯 SCOPE & BOUNDARIES
- **Organization**: You ONLY work on repositories under the `meeks` organization (e.g., `meeks/ai-electronbun-todo-app`).
- **DO NOT work on**: `meeks-ai`, `michael`, or any other organization/personal repos.
- **DO NOT create new repositories**. The repo already exists. It is cloned locally in the workspace (which is your current working directory).
- **DO NOT edit `.git` files** unless explicitly asked to resolve a git conflict or rebase issue.
### 🏗️ REPOSITORY WORKFLOW (MANDATORY)
Every change MUST follow this exact workflow:
1. **Checkout master**: Always start from the latest master branch.
```bash
git checkout master && git pull origin master
```
2. **Create branch**: Use Angular convention for branch naming:
```bash
# Types: feat, fix, chore, docs, style, refactor, test, build, ci, perf
# Branch names MUST include a descriptive name, not just the issue number or numbers.
git checkout -b feat/issue-<number>-descriptive-name
```
- `feat/` for new features
- `fix/` for bug fixes
- `chore/` for maintenance tasks
- `docs/` for documentation
- `style/` for code style (formatting, semicolons, etc.)
- `refactor/` for code refactoring (no behavior change)
- `test/` for adding or updating tests
- `build/` for build system changes
- `ci/` for CI/CD pipeline changes
- `perf/` for performance improvements
3. **Implement changes**: Edit files using `edit_file` or `write_file`. Make targeted, incremental changes.
4. **Commit**: Use conventional commit messages:
```bash
git add <files>
git commit -m "type: brief description of changes"
# Examples:
# git commit -m "fix: update dev script to use vite dev server"
# git commit -m "feat: add localStorage persistence to todo store"
```
5. **Push**: Push your branch to origin:
```bash
git push origin feat/descriptive-name
```
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 the tools directly.
The PR description/body MUST follow the template below.
### 📋 PR TEMPLATE (MANDATORY)
Every PR body MUST use this exact template:
```
<!-- 🤖 GITEA AUTOMATION BLOCK -->
closes #<ISSUE_NUMBER>
Impact Radius: [Auth, Database, UI Component, API Endpoint, etc.]
---
## 📝 Summary
<!-- Short summary of what this PR introduces and why it is needed. -->
<YOUR_SUMMARY_HERE>
---
## 🛠️ Technical Implementation
- [ ] **Database:** [Schema migration added / No changes]
- [ ] **Dependencies:** [Upgraded Package X / No new packages]
- [ ] **Breaking Changes:** [Yes / No] -> *If yes, explain downstream impact:*
---
## 🧪 Verification & Testing
### Manual Verification Steps
1. <STEP_1>
2. <STEP_2>
3. <STEP_3>
### Automated Test Status
- [ ] Unit Tests [Passed / Added]
- [ ] Integration/E2E Tests [Passed / Added]
---
## 🚨 Risk & Rollback Strategy
- **Deployment Caveats:** [None / Specific env vars needed]
- **Rollback Plan:** [Revert commit / toggle feature flag]
---
## 📋 Quality Checklist
- [ ] Code follows project style guidelines and architectural patterns.
- [ ] Documentation (README, inline comments) is updated.
- [ ] Secure practices followed (no hardcoded secrets, input sanitized).
```
### 🌐 RESEARCH BEFORE ACTING (MANDATORY)
Before writing any code or making any changes, you MUST:
1. **Search online** for documentation, known solutions, package APIs, error explanations, and platform-specific behavior.
- Use web search tools for: library docs, error messages, OS-specific quirks, framework conventions.
- Do NOT guess at APIs or behavior you are not certain about — look them up first.
- Examples: package.json script syntax, cross-platform shell commands, framework lifecycle hooks, etc.
2. **Identify uncertainties** in the issue or PR:
- Is the expected behavior clearly defined?
- Are there platform constraints you don't know about (Windows vs Linux vs macOS)?
- Are there user preferences not stated?
- Could multiple approaches work and you're unsure which to pick?
3. **If ANY uncertainty exists** — STOP and ask before implementing:
- Post a comment on the issue or PR (using `add_comment_to_issue` or `add_comment` tool) with your specific question(s).
- List the approaches you are considering and ask which is preferred.
- **Your comment MUST include the following marker on its own line at the very end:**
```
<!-- agent:awaiting-reply -->
```
This tells the system you are explicitly waiting for a human response, and prevents you from being re-dispatched to repeat the same question.
- Do NOT proceed with implementation until you receive a reply.
- Do NOT make assumptions and proceed silently.
- Do NOT repeat the same question in subsequent runs — if you already asked, wait.
4. **After researching and confirming requirements**, then implement.
⚠️ **CRITICAL**: An assumption that turns out wrong wastes everyone's time. Always prefer asking over guessing.
---
### 🔍 ISSUE HANDLING WORKFLOW
#### When Processing an Issue:
1. **Read the full issue description** and all comments carefully.
2. **Check AGENTS.md** in the repo root for project-specific conventions, verification steps, and coding standards.
3. **Research online** — search for relevant docs, solutions, and platform behavior before writing any code.
4. **Identify ambiguities** — if the issue is unclear, missing context, or has multiple valid approaches, post a clarifying comment on the issue and STOP. Wait for a human response before proceeding.
5. **Assess severity**:
- **Critical/High**: Fix immediately (e.g., broken builds, data loss, security issues, production bugs).
- **Medium**: Review and fix (e.g., missing features, poor UX, technical debt).
- **Low**: Skip or defer (e.g., cosmetic issues, minor typos, nitpicks).
6. **Formulate a plan** based on the issue description, research findings, and AGENTS.md.
7. **Implement the fix** following the repository workflow above.
8. **Verify the fix**:
- If AGENTS.md has verification steps, follow them.
- Otherwise, review the diff vs master and validate code quality.
- Research dependencies to ensure code standards are met.
9. **Create a PR** linking the issue using the dedicated `create_pull_request` tool. Do NOT use the `tea` CLI via `run_command`.
10. **Comment on the issue** (using `add_comment_to_issue`) immediately after PR creation, stating the PR number/link and a brief summary.
11. **An issue is DONE when the connected PR is merged** (you cannot merge yourself — leave it for humans).
#### When Fixing/Updating a PR (addressing review feedback):
1. **Read all review comments and change requests** on the PR carefully.
2. **Research online** for any technology or approach mentioned in the feedback you are not 100% sure about.
3. **If any feedback is ambiguous** — post a clarifying comment on the PR asking for clarification. Do NOT guess what the reviewer meant. Stop and wait for a reply.
4. **Once feedback is clear**, implement fixes on the existing branch (do NOT create a new branch or PR).
5. **Push and comment** on the PR with a summary of all changes made.
#### When Reviewing a PR:
1. **DO NOT write files, make commits, push branches, or create any new PRs**. Your only task is to review the existing PR.
2. **Research online** any technology, library, or approach used in the PR that you are not certain about before critiquing it.
3. **Check out the diff** vs master: `git diff origin/master...HEAD`.
4. **Review code quality**:
- Does it follow project conventions (check AGENTS.md)?
- Are there security issues?
- Is there proper error handling?
- Are edge cases covered?
- Are dependencies used correctly?
5. **Grade the severity** of any issues found:
- **Critical**: Blocker, must fix before merge.
- **Medium**: Should fix, but can merge with notes.
- **Low**: Nice-to-have, can defer.
6. **Post a review comment on the PR** with:
- Files and line numbers with markup visualization.
- Severity grade.
- Specific feedback and suggested fixes.
7. **If you find something you don't understand** — ask a question in the PR comment rather than raising a false alarm.
8. **DO NOT fix the issues yourself** during review. The author (another agent or human) will fix them in the next loop.
### 🚨 ERROR HANDLING
- If you cannot fix an issue, **comment on the issue** with:
- What you tried.
- What code changes you attempted.
- Why the fix failed.
- Do NOT mark the issue as done. The issue is only done when the PR is merged.
- If verification fails, continue debugging until it passes.
### 📝 COMMUNICATION
- **Issues**: Use for describing problems and asking clarifying questions when requirements are unclear.
- **PRs**: Use for proposing changes with detailed explanations.
- **PR Comments**: Use for review feedback, questions, and status updates.
- **When uncertain**: ALWAYS post a question as a comment and stop work. Never silently assume.
- **When researching**: Use web search to look up docs, error messages, library APIs, and platform quirks before asking humans.
- Always be specific and actionable in your comments.
### 🛑 FORBIDDEN ACTIONS
- Creating new repositories.
- Editing `.git` files (unless explicitly resolving a git issue).
- Merging PRs yourself (leave for humans).
- Working on non-`meeks` organization repos.
- Skipping AGENTS.md when available.
- Making unverified changes.
### ✅ SUCCESS CRITERIA
An issue is resolved when:
1. A PR is created with the fix.
2. The PR is linked to the issue (using `closes #N`).
3. The fix has been verified.
4. The PR has been reviewed (by another agent or human).
5. The PR is merged by a human.
You are the expert. Take charge. Follow the workflow exactly.
"""
+47
View File
@@ -0,0 +1,47 @@
import logging
from typing import Any, Callable
from core.agent import BaseAgent
from core.prompts import COORDINATOR_SYSTEM_PROMPT
from core.coordinator_tools import CoordinatorTools
logger: logging.Logger = logging.getLogger("agent-coordinator")
class CoordinatorNoToolCalledError(Exception):
"""Raised when the Coordinator Agent completes execution without calling any routing tool."""
pass
class CoordinatorAgent(BaseAgent):
"""AI agent that coordinates Gitea issues and decides the next action."""
def __init__(self, model_name: str) -> None:
super().__init__(model_name)
self.system_prompt = COORDINATOR_SYSTEM_PROMPT
async def decide_action(
self,
mission: str,
planning_tools: list[Callable[..., Any]],
coord_tools: CoordinatorTools,
) -> str:
"""Run the Coordinator Agent and ensure a tool is called."""
coord_tools_list: list[Callable[..., Any]] = [
coord_tools.propose_plan,
coord_tools.start_implementation,
coord_tools.answer_question,
coord_tools.close_issue,
coord_tools.take_no_action,
]
combined_tools: list[Callable[..., Any]] = planning_tools + coord_tools_list
logger.info("Running CoordinatorAgent to decide action...")
response_text: str = await self.run_with_tools(mission, combined_tools)
if not coord_tools.tool_called:
logger.warning("CoordinatorAgent did not call any tools!")
raise CoordinatorNoToolCalledError(
"CoordinatorAgent failed to call a routing tool during execution."
)
return response_text
+80
View File
@@ -0,0 +1,80 @@
import logging
from typing import Any
logger: logging.Logger = logging.getLogger("coordinator-tools")
class CoordinatorTools:
"""Tools exposed to the Coordinator Agent for routing decisions."""
def __init__(self) -> None:
self.tool_called: bool = False
self.action: str = "NO_ACTION"
self.arguments: dict[str, Any] = {}
def propose_plan(self, plan: str, issue_number: int) -> str:
"""Propose a step-by-step implementation plan to resolve the issue.
Use this when a code change is needed but no plan has been proposed yet,
or a plan was proposed but the human replied with feedback/changes.
Args:
plan: The detailed implementation plan.
issue_number: The Gitea issue number.
"""
logger.info(f"Coordinator tool 'propose_plan' called for issue #{issue_number}")
self.tool_called = True
self.action = "PROPOSE_PLAN"
self.arguments = {"plan": plan, "issue_number": issue_number}
return "Plan proposal recorded successfully."
def start_implementation(self, approved_plan: str, issue_number: int) -> str:
"""Enqueue/start implementation of the approved plan.
Use this ONLY if a plan was proposed and the human explicitly approved/greenlit it.
Args:
approved_plan: The plan that was approved, including any human feedback.
issue_number: The Gitea issue number.
"""
logger.info(f"Coordinator tool 'start_implementation' called for issue #{issue_number}")
self.tool_called = True
self.action = "EXECUTE_PLAN"
self.arguments = {"approved_plan": approved_plan, "issue_number": issue_number}
return "Implementation start recorded successfully."
def answer_question(self, answer: str, issue_number: int) -> str:
"""Provide a clear, helpful response to a question or information request.
Use this if the issue is just a question (no code changes needed).
Args:
answer: The clear, helpful answer to the question.
issue_number: The Gitea issue number.
"""
logger.info(f"Coordinator tool 'answer_question' called for issue #{issue_number}")
self.tool_called = True
self.action = "ANSWER_QUESTION"
self.arguments = {"answer": answer, "issue_number": issue_number}
return "Answer recorded successfully."
def close_issue(self, comment: str, issue_number: int) -> str:
"""Close the issue.
Use this if the human confirmed they are satisfied or gave approval to close.
Args:
comment: A polite final comment explaining the closing of the issue.
issue_number: The Gitea issue number.
"""
logger.info(f"Coordinator tool 'close_issue' called for issue #{issue_number}")
self.tool_called = True
self.action = "CLOSE_ISSUE"
self.arguments = {"comment": comment, "issue_number": issue_number}
return "Close issue action recorded successfully."
def take_no_action(self) -> str:
"""Take no action on the issue.
Use this if the issue is already resolved or cannot proceed.
"""
logger.info("Coordinator tool 'take_no_action' called")
self.tool_called = True
self.action = "NO_ACTION"
self.arguments = {}
return "No action recorded successfully."
+763
View File
@@ -0,0 +1,763 @@
"""Dispatches work to a specialized task processor, one repo at a time."""
import logging
import re
import os
import subprocess
from abc import ABC, abstractmethod
from typing import Any, Callable
from core.queue import WorkItem
from core.coding_agent import CodingAgent
from core.planning_agent import PlanningAgent
from core.coordinator_agent import CoordinatorAgent, CoordinatorNoToolCalledError
from core.factory import AgentFactory
from gitea.tools.coding_tools import CodingTools
from gitea.tools.research_tools import ResearchTools
from gitea.tools.gitea_tools import GiteaTools
from gitea.client import GiteaClient
from core.coordinator_tools import CoordinatorTools
from gitea.workspace import WorkspaceManager
from gitea.config import AGENT_MODEL_ID
from gitea.models import CommentModel, PullRequestFileModel, PullRequestModel, IssueModel
logger: logging.Logger = logging.getLogger("agent-dispatcher")
AGENT_USERNAMES: frozenset[str] = frozenset({"meeks-ai", "agent-bot"})
CLOSE_KEYWORDS_PATTERN: re.Pattern[str] = re.compile(
rf"\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+#(\d+)\b",
re.IGNORECASE
)
def _find_pr_for_issue_helper(client: GiteaClient, repo_full_name: str, issue_number: int) -> PullRequestModel | None:
"""Find an open pull request that addresses the given issue number."""
owner, repo_name = repo_full_name.split("/")
try:
prs = client.list_repo_pull_requests(owner, repo_name)
for pr in prs:
ref = pr.head.get("ref", "") if pr.head else ""
if re.search(rf"(?<!\d){issue_number}(?!\d)", ref):
return pr
body = pr.body or ""
title = pr.title or ""
matches = CLOSE_KEYWORDS_PATTERN.findall(body) + CLOSE_KEYWORDS_PATTERN.findall(title)
if any(int(m) == issue_number for m in matches):
return pr
issue_ref_pattern = re.compile(rf"(?<!\w)#{issue_number}\b")
if issue_ref_pattern.search(title) or issue_ref_pattern.search(body):
return pr
except Exception as e:
logger.warning(f"Error checking PRs for issue #{issue_number} in {repo_full_name}: {e}")
return None
def _find_issues_for_pr_helper(pr_body: str) -> list[int]:
"""Extract referenced issue numbers from the PR body."""
matches = CLOSE_KEYWORDS_PATTERN.findall(pr_body)
return list(set(int(m) for m in matches))
def _is_awaiting_reply_helper(comments: list[CommentModel]) -> bool:
"""Return True if the agent's most recent comment contains the
awaiting-reply marker AND no human has commented after it.
"""
if not comments:
return False
# Find the last agent comment index
last_agent_idx: int = -1
for i, c in enumerate(comments):
if c.user and c.user.login in AGENT_USERNAMES:
last_agent_idx = i
if last_agent_idx == -1:
return False
last_agent_comment = comments[last_agent_idx]
body = last_agent_comment.body or ""
# The agent explicitly embeds this marker when it is waiting for input
if "<!-- agent:awaiting-reply -->" not in body:
return False
# Check if any human replied AFTER the last agent comment
for c in comments[last_agent_idx + 1:]:
if c.user and c.user.login not in AGENT_USERNAMES:
return False # Human replied — we can proceed
return True # Agent signalled wait, no human replied yet
class TaskProcessor(ABC):
"""Abstract base class for processing Gitea tasks."""
def __init__(
self,
client: GiteaClient,
tools: GiteaTools,
model_name: str,
repo: str,
item: WorkItem,
ai_username: str,
) -> None:
self.client = client
self.tools = tools
self.model_name = model_name
self.repo = repo
self.item = item
self.ai_username = ai_username
self.owner, self.repo_name = repo.split("/")
self.workspace = WorkspaceManager()
self.repo_path = self.workspace.get_repo_path(repo)
self.coding_tools = CodingTools(str(self.repo_path))
self.research_tools = ResearchTools()
self.planning_tools: list[Callable[..., Any]] = [
self.tools.get_issue,
self.tools.get_pull_request,
self.tools.list_issues,
self.tools.list_pull_requests,
self.tools.get_file_content,
self.tools.get_issue_comments,
self.tools.get_pull_request_comments,
self.tools.get_pull_request_diff,
self.tools.get_pull_request_patch,
self.coding_tools.list_files,
self.coding_tools.read_file,
self.coding_tools.grep_search,
self.coding_tools.get_working_directory,
self.coding_tools.run_command,
self.research_tools.web_search,
self.research_tools.fetch_url,
]
self.coding_tools_list: list[Callable[..., Any]] = [
self.tools.get_issue,
self.tools.get_pull_request,
self.tools.list_issues,
self.tools.list_pull_requests,
self.tools.get_file_content,
self.tools.create_pull_request,
self.tools.update_pull_request,
self.tools.add_label_to_issue,
self.tools.add_label_to_pr,
self.tools.create_branch,
self.tools.commit_file,
self.tools.create_issue,
self.tools.add_comment_to_issue,
self.tools.close_issue,
self.tools.close_pull_request,
self.tools.get_issue_comments,
self.tools.get_pull_request_comments,
self.tools.add_comment,
self.tools.add_label,
self.tools.update_file,
self.tools.get_pull_request_diff,
self.tools.get_pull_request_patch,
self.tools.approve_pull_request,
self.tools.request_changes,
self.coding_tools.list_files,
self.coding_tools.read_file,
self.coding_tools.write_file,
self.coding_tools.edit_file,
self.coding_tools.run_command,
self.coding_tools.grep_search,
self.coding_tools.get_working_directory,
self.research_tools.web_search,
self.research_tools.fetch_url,
]
@abstractmethod
async def process(self, attempt_limit: int) -> str:
"""Execute the task flow, including planning, coding, or coordination."""
pass
class PRTaskProcessor(TaskProcessor):
"""Processes Gitea pull requests (code reviews and bug fixes)."""
def _build_pr_mission(self, pr_info: PullRequestModel, is_own_pr: bool) -> str:
pr_number = self.item.task_number
pr_details = pr_info.model_dump_json(indent=2)
pr_diff = ""
try:
pr_diff = self.client.get_pull_request_diff(self.owner, self.repo_name, pr_number)
except Exception as e:
logger.warning(f"Could not fetch PR diff for #{pr_number}: {e}")
pr_diff = f"Error fetching diff: {e}"
pr_files: list[PullRequestFileModel] = []
try:
pr_files = self.client.get_pull_request_files(self.owner, self.repo_name, pr_number)
except Exception:
pass
files_summary = "\n".join([f"- {f.filename}" for f in pr_files]) if pr_files else "No files available."
comments: list[CommentModel] = []
try:
comments = self.client.get_pull_request_comments(self.owner, self.repo_name, pr_number)
if not isinstance(comments, list):
comments = []
except Exception:
pass
reviews: list[dict[str, Any]] = []
try:
reviews = self.client.get_pr_reviews(self.owner, self.repo_name, pr_number)
if not isinstance(reviews, list):
reviews = []
except Exception:
pass
timeline: list[dict[str, Any]] = []
for c in comments:
timeline.append({
"timestamp": c.created_at or "",
"user": c.user.login,
"type": "comment",
"body": c.body,
"by_ai": "Reviewed by AI Agent" in c.body or c.user.login == self.ai_username
})
for r in reviews:
r_user = (r.get("user") or {}).get("login", "unknown")
r_body = r.get("body", "")
r_state = r.get("state", "")
timeline.append({
"timestamp": r.get("submitted_at") or r.get("updated_at") or "",
"user": r_user,
"type": "review",
"body": f"[{r_state}] {r_body}",
"by_ai": r_user == self.ai_username
})
timeline.sort(key=lambda x: x["timestamp"])
last_action_by_ai = False
if timeline:
last_action_by_ai = timeline[-1]["by_ai"]
if last_action_by_ai:
logger.info(f"PR #{pr_number} already addressed by AI. Skipping.")
return f"SKIP: PR #{pr_number} has already been addressed by AI. No new action needed."
comments_str = "\n".join([
f"- @{c.user.login} ({c.created_at}): {c.body}"
for c in comments
]) if comments else "No comments yet."
reviews_str = "\n".join([
f"- @{(r.get('user') or {}).get('login')} ({r.get('submitted_at')}): [{r.get('state')}] {r.get('body')}"
for r in reviews
]) if reviews else "No reviews yet."
connected_issues_ctx = ""
pr_body = pr_info.body or ""
linked_issues = _find_issues_for_pr_helper(pr_body)
if linked_issues:
issues_details = []
for issue_num in linked_issues:
try:
issue = self.client.get_issue(self.owner, self.repo_name, issue_num)
issue_comments = self.client.get_issue_comments(self.owner, self.repo_name, issue_num)
comments_list = "\n".join([
f" - @{c.user.login} ({c.created_at}): {c.body}"
for c in issue_comments
]) if issue_comments else " No comments yet."
issues_details.append(
f"### Connected Issue #{issue_num}: {issue.title}\n"
f"Author: @{issue.user.login} (created {issue.created_at})\n"
f"Description:\n{issue.body or 'No description'}\n"
f"Discussion:\n{comments_list}"
)
except Exception as e:
logger.warning(f"Could not fetch connected issue #{issue_num}: {e}")
if issues_details:
connected_issues_ctx = "\n---\n\n## 📋 CONNECTED ISSUE CONTEXT\n" + "\n\n".join(issues_details)
pr_head_branch = pr_info.head.get('ref', 'unknown') if pr_info.head else "unknown"
pr_base_branch = pr_info.base.get('ref', 'unknown') if pr_info.base else "unknown"
pr_state = pr_info.state
pr_created = pr_info.created_at or "unknown"
is_fixing_pr = is_own_pr or any(r.get("state") == "REQUEST_CHANGES" for r in reviews)
if is_fixing_pr:
instructions = (
f" Note: The repository is located locally at '{self.repo_path}'. This repository directory is your current working directory (cwd). You can verify this via the `get_working_directory` tool.\n"
f" Your task is to FIX/UPDATE this PR by addressing comments/change requests.\n"
f" DO NOT create a new branch or PR. Follow this exact workflow:\n"
f" 1. Checkout the PR's head branch: `git checkout {pr_head_branch}`\n"
f" 2. Implement the requested fixes or changes on this branch.\n"
f" 3. Verify your fixes and run verification/tests.\n"
f" 4. Commit and push the changes directly: `git add <files> && git commit -m \"fix: address feedback\" && git push origin {pr_head_branch}`\n"
f" 5. After pushing, comment on the PR (using the `add_comment` tool) with a summary of the fixes implemented."
)
else:
instructions = (
f" Note: The repository is located locally at '{self.repo_path}'. This repository directory is your current working directory (cwd). You can verify this via the `get_working_directory` tool.\n"
" CRITICAL: You are ONLY reviewing this PR. DO NOT edit files, DO NOT make commits, DO NOT push branches, and DO NOT create any new PRs.\n"
"1. Read the PR diff carefully.\n"
"2. Analyze the changes for correctness, quality, and potential issues.\n"
"3. Check for: code quality, security issues, edge cases, test coverage.\n"
"4. If the PR is good: approve it (using approve_pull_request tool) with a meaningful comment.\n"
"5. If the PR has issues: request changes (using request_changes tool) with specific feedback.\n"
"6. Post your review comment on the PR (using add_comment tool).\n"
"IMPORTANT: Never merge the PR yourself - that is handled by humans."
)
return (
f"Your mission is to process PR #{pr_number} in {self.repo}.\n\n"
f"PR: {pr_info.title}\n"
f"Author: @{pr_info.user.login if pr_info.user else 'unknown'}\n"
f"Branch: {pr_head_branch}{pr_base_branch}\n"
f"State: {pr_state} (created {pr_created})\n\n"
f"Description:\n{pr_info.body or 'No description'}\n\n"
f"Files Changed ({len(pr_files)}):\n{files_summary}\n\n"
f"Timeline Comments:\n{comments_str}\n\n"
f"Reviews:\n{reviews_str}\n\n"
f"{connected_issues_ctx}\n\n"
"BEFORE MAKING ANY CHANGES:\n"
" - Search online for any technology, API, or behavior you are not 100% certain about.\n"
" - Read ALL review comments and change requests carefully.\n"
" - If any review comment is ambiguous or unclear:\n"
" → Post a clarifying comment on the PR (using the `add_comment` tool) with your specific question(s).\n"
" → End the comment with the marker: <!-- agent:awaiting-reply --> on its own line.\n"
" → STOP. Do NOT implement anything until a human replies. The system will re-dispatch you once a human responds.\n"
" - Never assume or guess what a reviewer meant. Always prefer asking over guessing.\n\n"
f"Instructions:\n{instructions}"
)
async def process(self, attempt_limit: int) -> str:
try:
pr_detail = self.client.get_pull_request(self.owner, self.repo_name, self.item.task_number)
except Exception as e:
logger.warning(f"Error fetching PR #{self.item.task_number} detail: {e}")
return f"FAILED: Could not fetch details for PR #{self.item.task_number}."
is_own_pr = (pr_detail.user and pr_detail.user.login == self.ai_username)
is_requested_reviewer = any(r.login == self.ai_username for r in pr_detail.requested_reviewers)
if not is_own_pr and not is_requested_reviewer:
logger.info(f"PR #{self.item.task_number}: Agent is not a requested reviewer. Skipping.")
return f"SKIP: Agent is not a requested reviewer on PR #{self.item.task_number}."
pr_comments = []
try:
pr_comments = self.client.get_pull_request_comments(self.owner, self.repo_name, self.item.task_number)
except Exception:
pass
if _is_awaiting_reply_helper(pr_comments):
logger.info(f"PR #{self.item.task_number}: agent asked a question and is awaiting a human reply. Skipping.")
return f"SKIP: Awaiting human reply on PR #{self.item.task_number}."
base_mission = self._build_pr_mission(pr_detail, is_own_pr)
if base_mission.startswith("SKIP:"):
logger.info(f"Skipping task #{self.item.task_number}: {base_mission}")
return base_mission
for attempt in range(1, attempt_limit + 1):
try:
logger.info(f"Starting Planning Phase for PR #{self.item.task_number} (attempt {attempt})")
planning_mission = (
f"PHASE 1: PLANNING PHASE\n\n"
f"Your task is to research the problem, analyse the repository structure, and produce a detailed implementation plan.\n"
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 = PlanningAgent(self.model_name)
plan = await planning_agent.run_with_tools(planning_mission, self.planning_tools)
logger.info(f"Starting Coding Phase for PR #{self.item.task_number} (attempt {attempt})")
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, self.coding_tools_list)
return response
except Exception as e:
logger.error(f"Error processing PR #{self.item.task_number} (attempt {attempt}/{attempt_limit}): {e}")
if attempt == attempt_limit:
return f"FAILED after {attempt_limit} attempts: {str(e)}"
return f"FAILED: PR #{self.item.task_number} not processed."
class IssueTaskProcessor(TaskProcessor):
"""Processes Gitea issues (acting as coordinator, then coding)."""
def _build_issue_mission(self, issue_info: IssueModel, branch_name: str) -> str:
issue_number = self.item.task_number
issue_body = issue_info.body or "No description provided."
issue_labels = [lbl.name for lbl in issue_info.labels]
issue_user = issue_info.user.login if issue_info.user else "unknown"
issue_created = issue_info.created_at or "unknown"
title = issue_info.title
comments: list[CommentModel] = []
try:
comments = self.client.get_issue_comments(self.owner, self.repo_name, issue_number)
except Exception:
pass
labels_str = f"Labels: {', '.join(issue_labels)}" if issue_labels else "Labels: none"
comments_str = "\n".join([
f"- @{c.user.login} ({c.created_at}): {c.body}"
for c in comments
]) if comments else "No comments yet."
return (
f"Your mission is to resolve issue #{issue_number} in {self.repo}.\n\n"
f"Issue: {title}\n"
f"Author: @{issue_user} (created {issue_created})\n"
f"{labels_str}\n\n"
f"Description:\n{issue_body}\n\n"
f"Comments ({len(comments)}):\n{comments_str}\n\n"
f"Branch name: {branch_name}.\n\n"
"BEFORE WRITING ANY CODE:\n"
" - Search online for relevant documentation, known solutions, library APIs, and platform-specific behavior.\n"
" - If ANY part of the issue is unclear, ambiguous, or has multiple valid approaches:\n"
" → Post a comment on the issue using `add_comment_to_issue` with your specific question(s).\n"
" → List the approaches you are considering.\n"
" → End the comment with the marker: <!-- agent:awaiting-reply --> on its own line.\n"
" → STOP. Do NOT proceed until a human replies. The system will re-dispatch you once a human responds.\n"
" - Never assume or guess. Always prefer asking over guessing.\n\n"
"CRITICAL INSTRUCTIONS:\n"
f"1. The repo is already cloned locally at '{self.repo_path}'. DO NOT create a new repository.\n"
f" The repository directory is your current working directory (cwd). You can verify this via the `get_working_directory` tool.\n"
"2. Always start from master: `git checkout master && git pull origin master`\n"
"3. Branch from master: `git checkout -b <type>/issue-<number>-<descriptive-name>`\n"
" Branch names MUST include a descriptive name (words/hyphens), not just the issue number.\n"
" Types: feat, fix, chore, docs, style, refactor, test, build, ci, perf\n"
"4. Use `edit_file`/`write_file` for code changes, then `git add` and `git commit` via `run_command`.\n"
"5. Push: `git push origin <branch>` via `run_command`.\n"
"6. Create PR: Use the `create_pull_request` tool (do NOT use Gitea's `tea` CLI or GitHub's `gh` CLI in run_command, as they can hang/freeze interactively).\n"
" PR description MUST include the Gitea automation template with 'closes #<ISSUE>'.\n"
"7. IMPORTANT: After successfully creating the pull request, you MUST comment on the issue (using the `add_comment_to_issue` tool) with the PR number, PR link, and summary.\n"
"8. Check AGENTS.md in repo root for project conventions and verification steps.\n"
"9. Grade severity: Critical/High = fix, Medium = review/fix, Low = skip.\n"
"An issue is DONE when the connected PR is merged (you cannot merge yourself).\n"
"DO NOT edit .git files unless explicitly resolving a git issue.\n"
"DO NOT work on non-meeks organization repos."
)
async def process(self, attempt_limit: int) -> str:
# Check if there is an existing PR for the issue
existing_pr = _find_pr_for_issue_helper(self.client, self.repo, self.item.task_number)
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(self.owner, self.repo_name, existing_pr.number)
has_request_changes = any(r.get("state") == "REQUEST_CHANGES" for r in reviews)
except Exception as e:
logger.warning(f"Error checking reviews for PR #{existing_pr.number}: {e}")
if not is_wip and not has_request_changes:
logger.info(f"Issue #{self.item.task_number} already has open PR #{existing_pr.number}. Skipping.")
return f"SKIP: A pull request (PR #{existing_pr.number}) addressing issue #{self.item.task_number} already exists."
# Check comments on issue and PR
issue_comments = []
try:
issue_comments = self.client.get_issue_comments(self.owner, self.repo_name, self.item.task_number)
except Exception:
pass
pr_comments = []
if existing_pr:
try:
pr_comments = self.client.get_pull_request_comments(self.owner, self.repo_name, existing_pr.number)
except Exception:
pass
if _is_awaiting_reply_helper(issue_comments) or _is_awaiting_reply_helper(pr_comments):
logger.info(f"Issue #{self.item.task_number}: awaiting human reply. Skipping.")
return f"SKIP: Awaiting human reply on issue #{self.item.task_number} or PR."
issue_info = self.item.task_info
assert isinstance(issue_info, IssueModel)
title = issue_info.title
issue_body = issue_info.body or "No description provided."
issue_comments_str = "\n".join([
f"- @{c.user.login} ({c.created_at}): {c.body}" for c in issue_comments
]) if issue_comments else "No comments yet."
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(self.owner, self.repo_name, existing_pr.number)
reviews_str = "\n".join([
f"- @{r.get('user', {}).get('login')} ({r.get('submitted_at')}): [{r.get('state')}] {r.get('body')}"
for r in reviews
]) if reviews else "No reviews yet."
except Exception:
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_mission = (
f"Analyzing issue #{self.item.task_number} in '{self.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"
)
for attempt in range(1, attempt_limit + 1):
try:
coord_tools = CoordinatorTools()
coordinator_agent = CoordinatorAgent(self.model_name)
logger.info(f"Analyzing conversation state for issue #{self.item.task_number}...")
await coordinator_agent.decide_action(state_analysis_mission, self.planning_tools, coord_tools)
action = coord_tools.action
logger.info(f"Coordinator Decided Action: {action} (tool_called={coord_tools.tool_called})")
if action == "PROPOSE_PLAN":
comment_body = coord_tools.arguments.get("comment_body", "")
if not comment_body:
plan = coord_tools.arguments.get("plan", "")
comment_body = (
f"### Proposed Implementation Plan\n\n"
f"{plan}\n\n"
f"Is this plan ok for implementation or do you have any comments/changes?\n"
f"<!-- agent:plan-proposal -->\n"
f"<!-- agent:awaiting-reply -->"
)
self.client.add_comment(self.owner, self.repo_name, self.item.task_number, comment_body)
return f"POSTED_COMMENT: PROPOSE_PLAN comment posted to issue #{self.item.task_number}."
elif action == "ANSWER_QUESTION":
comment_body = coord_tools.arguments.get("comment_body", "")
if not comment_body:
answer = coord_tools.arguments.get("answer", "")
comment_body = (
f"{answer}\n\n"
f"Is this answer satisfactory?\n"
f"<!-- agent:question-response -->\n"
f"<!-- agent:awaiting-reply -->"
)
self.client.add_comment(self.owner, self.repo_name, self.item.task_number, comment_body)
return f"POSTED_COMMENT: ANSWER_QUESTION comment posted to issue #{self.item.task_number}."
elif action == "CLOSE_ISSUE":
comment = coord_tools.arguments.get("comment", "Closing the issue as resolved.")
self.client.add_comment(self.owner, self.repo_name, self.item.task_number, comment)
self.client.close_issue(self.owner, self.repo_name, self.item.task_number)
return f"CLOSED_ISSUE: Issue #{self.item.task_number} closed."
elif action == "NO_ACTION":
return f"NO_ACTION: No action taken on issue #{self.item.task_number}."
elif action == "EXECUTE_PLAN":
approved_plan = coord_tools.arguments.get("approved_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 #{self.item.task_number}")
clean_title = re.sub(r'[^a-zA-Z0-9\s-]', '', title).strip().lower()
title_words = clean_title.split()[:5]
desc_suffix = "-".join(title_words)
if not desc_suffix:
desc_suffix = "fix-issue"
branch_name = f"fix/issue-{self.item.task_number}-{desc_suffix}"
try:
subprocess.run(["git", "checkout", "master"], cwd=str(self.repo_path), check=True)
subprocess.run(["git", "pull", "origin", "master"], cwd=str(self.repo_path), check=True)
subprocess.run(["git", "branch", "-D", branch_name], cwd=str(self.repo_path), stderr=subprocess.DEVNULL)
subprocess.run(["git", "checkout", "-b", branch_name], cwd=str(self.repo_path), check=True)
subprocess.run(["git", "commit", "--allow-empty", "-m", f"WIP: start implementation for issue #{self.item.task_number}"], cwd=str(self.repo_path), check=True)
subprocess.run(["git", "push", "origin", branch_name], cwd=str(self.repo_path), check=True)
pr_title = f"WIP: {title}"
pr_description = f"Work in progress for issue #{self.item.task_number}."
pr_to_use = self.client.create_pull_request(
self.owner, self.repo_name, head=branch_name, base="master", title=pr_title, description=pr_description
)
pr_link = pr_to_use.html_url or f"{self.client.base_url}/{self.repo}/pulls/{pr_to_use.number}"
start_comment = f"Started work on PR #{pr_to_use.number} ({pr_link})."
self.client.add_comment(self.owner, self.repo_name, self.item.task_number, start_comment)
logger.info(f"Successfully created WIP PR #{pr_to_use.number} on branch '{branch_name}'")
except Exception as e:
logger.error(f"Failed to create WIP PR for issue #{self.item.task_number}: {e}")
return f"FAILED to create WIP PR: {e}"
base_mission = self._build_issue_mission(issue_info, branch_name)
coding_mission = (
f"PHASE 2: EXECUTION/CODING PHASE\n\n"
f"You are implementing changes for issue #{self.item.task_number} in repository '{self.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{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='{self.owner}', repo='{self.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='{self.owner}', repo='{self.repo_name}', issue_number={self.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 #{self.item.task_number} on branch '{branch_name}'")
coding_agent = CodingAgent(self.model_name)
response = await coding_agent.run_with_tools(coding_mission, self.coding_tools_list)
logger.info(f"Agent response for issue #{self.item.task_number}: {response}")
return response
except CoordinatorNoToolCalledError as e:
logger.error(f"Coordinator error on issue #{self.item.task_number} (attempt {attempt}/{attempt_limit}): {e}")
if attempt == attempt_limit:
return f"FAILED: Coordinator did not call any tools after {attempt_limit} attempts."
except Exception as e:
logger.error(f"Error processing issue #{self.item.task_number} (attempt {attempt}/{attempt_limit}): {e}")
if attempt == attempt_limit:
return f"FAILED after {attempt_limit} attempts: {str(e)}"
return f"FAILED: Issue #{self.item.task_number} not processed."
class AgentDispatcher:
"""Dispatches work to a specialized task processor, one repo at a time."""
def __init__(
self,
client: GiteaClient,
tools: GiteaTools,
model_name: str = AGENT_MODEL_ID,
max_retries: int = 2,
) -> None:
self._client = client
self._tools = tools
self._model_name = model_name
self._max_retries = max_retries
async def dispatch(
self,
repo: str,
work_items: list[WorkItem],
) -> list[str]:
"""Dispatch all work for a single repo by handling specialized processor classes."""
workspace = WorkspaceManager()
repo_path = workspace.get_repo_path(repo)
original_cwd = os.getcwd()
changed_dir = False
if os.path.isdir(str(repo_path)):
os.chdir(str(repo_path))
changed_dir = True
results: list[str] = []
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:
processor: TaskProcessor
if item.task_type == "pr":
processor = PRTaskProcessor(
client=self._client,
tools=self._tools,
model_name=self._model_name,
repo=repo,
item=item,
ai_username=ai_username,
)
elif item.task_type == "issue":
processor = IssueTaskProcessor(
client=self._client,
tools=self._tools,
model_name=self._model_name,
repo=repo,
item=item,
ai_username=ai_username,
)
else:
logger.warning(f"Unknown task type: {item.task_type}")
results.append(f"SKIP: Unknown task type {item.task_type}")
continue
logger.info(f"Processing {item.task_type} #{item.task_number} via {processor.__class__.__name__}")
result = await processor.process(attempt_limit=self._max_retries)
results.append(result)
finally:
if changed_dir:
os.chdir(original_cwd)
return results
# Backward compatibility helper methods for unit tests
def _find_pr_for_issue(self, repo_full_name: str, issue_number: int) -> PullRequestModel | None:
return _find_pr_for_issue_helper(self._client, repo_full_name, issue_number)
def _is_awaiting_reply(self, comments: list[CommentModel]) -> bool:
return _is_awaiting_reply_helper(comments)
def _build_pr_mission(self, item: WorkItem) -> str:
pr_info = item.task_info
assert isinstance(pr_info, PullRequestModel)
processor = PRTaskProcessor(
client=self._client,
tools=self._tools,
model_name=self._model_name,
repo=item.repo_full_name,
item=item,
ai_username="meeks-ai",
)
return processor._build_pr_mission(pr_info, is_own_pr=False)
def _build_issue_mission(self, item: WorkItem) -> str:
issue_info = item.task_info
assert isinstance(issue_info, IssueModel)
processor = IssueTaskProcessor(
client=self._client,
tools=self._tools,
model_name=self._model_name,
repo=item.repo_full_name,
item=item,
ai_username="meeks-ai",
)
return processor._build_issue_mission(issue_info, "dummy-branch")
+94
View File
@@ -0,0 +1,94 @@
import logging
from gitea.client import GiteaClient
from core.interfaces import (
IssuesClient,
PullRequestsClient,
FilesClient,
RefsClient,
ReposClient,
)
from core.coding_agent import CodingAgent
from core.agent import CavemanAgent
from core.coordinator_agent import CoordinatorAgent
from core.planning_agent import PlanningAgent
from core.notification_agent import NotificationReaderAgent
from gitea.workspace import WorkspaceManager
logger: logging.Logger = logging.getLogger("core-factory")
class GiteaClientFactory:
"""Factory for creating Gitea client components with dependency injection support."""
@staticmethod
def create_full_client() -> GiteaClient:
return GiteaClient()
@staticmethod
def create_issues_client(client: GiteaClient | None = None) -> IssuesClient:
if client is None:
client = GiteaClient()
return client
@staticmethod
def create_prs_client(client: GiteaClient | None = None) -> PullRequestsClient:
if client is None:
client = GiteaClient()
return client
@staticmethod
def create_files_client(client: GiteaClient | None = None) -> FilesClient:
if client is None:
client = GiteaClient()
return client
@staticmethod
def create_refs_client(client: GiteaClient | None = None) -> RefsClient:
if client is None:
client = GiteaClient()
return client
@staticmethod
def create_repos_client(client: GiteaClient | None = None) -> ReposClient:
if client is None:
client = GiteaClient()
return client
class AgentFactory:
"""Factory for creating AI agent instances."""
@staticmethod
def create_coding_agent(model_name: str) -> CodingAgent:
logger.info(f"Factory creating CodingAgent with model: {model_name}")
return CodingAgent(model_name)
@staticmethod
def create_caveman_agent(model_name: str) -> CavemanAgent:
logger.info(f"Factory creating CavemanAgent with model: {model_name}")
return CavemanAgent(model_name)
@staticmethod
def create_coordinator_agent(model_name: str) -> CoordinatorAgent:
logger.info(f"Factory creating CoordinatorAgent with model: {model_name}")
return CoordinatorAgent(model_name)
@staticmethod
def create_planning_agent(model_name: str) -> PlanningAgent:
logger.info(f"Factory creating PlanningAgent with model: {model_name}")
return PlanningAgent(model_name)
@staticmethod
def create_notification_reader_agent(model_name: str) -> NotificationReaderAgent:
logger.info(f"Factory creating NotificationReaderAgent with model: {model_name}")
return NotificationReaderAgent(model_name)
class WorkspaceFactory:
"""Factory for creating workspace manager instances."""
@staticmethod
def create_workspace() -> WorkspaceManager:
logger.info("Factory creating WorkspaceManager")
return WorkspaceManager()
+182
View File
@@ -0,0 +1,182 @@
"""Interfaces for Gitea operations."""
from abc import ABC, abstractmethod
from typing import Any
from gitea.models import (
IssueModel,
PullRequestModel,
CommentModel,
LabelModel,
UserModel,
RepositoryModel,
PullRequestFileModel,
)
class IssuesClient(ABC):
"""Interface for Gitea issue operations."""
@abstractmethod
def list_repo_issues(self, owner: str, repo: str, state: str = "open") -> list[IssueModel]: ...
@abstractmethod
def get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel: ...
@abstractmethod
def close_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel: ...
@abstractmethod
def get_issue_comments(self, owner: str, repo: str, issue_number: int) -> list[CommentModel]: ...
@abstractmethod
def list_assigned_issues(self, owner: str, repo: str) -> list[IssueModel]: ...
@abstractmethod
def create_issue(
self,
owner: str,
repo: str,
title: str,
body: str,
labels: list[str] | None = None,
assignees: list[str] | None = None,
) -> IssueModel: ...
@abstractmethod
def add_comment(self, owner: str, repo: str, issue_number: int, body: str) -> CommentModel: ...
@abstractmethod
def add_label(self, owner: str, repo: str, issue_number: int, label: str) -> LabelModel: ...
class PullRequestsClient(ABC):
"""Interface for Gitea pull request operations."""
@abstractmethod
def list_repo_pull_requests(self, owner: str, repo: str, state: str = "open") -> list[PullRequestModel]: ...
@abstractmethod
def get_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel: ...
@abstractmethod
def close_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel: ...
@abstractmethod
def get_pull_request_comments(self, owner: str, repo: str, pull_number: int) -> list[CommentModel]: ...
@abstractmethod
def get_pr_reviews(self, owner: str, repo: str, pr_number: int) -> list[dict[str, Any]]: ...
@abstractmethod
def get_pull_request_diff(self, owner: str, repo: str, pull_number: int) -> str: ...
@abstractmethod
def get_pull_request_patch(self, owner: str, repo: str, pull_number: int) -> str: ...
@abstractmethod
def get_pull_request_files(self, owner: str, repo: str, pull_number: int) -> list[PullRequestFileModel]: ...
@abstractmethod
def list_assigned_pull_requests(self, owner: str, repo: str) -> list[PullRequestModel]: ...
@abstractmethod
def create_pull_request(
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]: ...
@abstractmethod
def request_changes_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]: ...
@abstractmethod
def dismiss_review_pr(
self, owner: str, repo: str, pr_number: int, review_id: int, message: str
) -> dict[str, Any]: ...
@abstractmethod
def assign_issue(self, owner: str, repo: str, issue_number: int, username: str) -> IssueModel: ...
class FilesClient(ABC):
"""Interface for Gitea file/content operations."""
@abstractmethod
def get_file_content(self, owner: str, repo: str, path: str, ref: str = "master") -> str | list[str]: ...
@abstractmethod
def update_file(
self, owner: str, repo: str, path: str, message: str, content: str, branch: str
) -> dict[str, Any]: ...
class RefsClient(ABC):
"""Interface for Gitea git ref operations."""
@abstractmethod
def update_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]: ...
@abstractmethod
def create_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]: ...
class ReposClient(ABC):
"""Interface for Gitea repository operations."""
@abstractmethod
def list_all_user_repos(self) -> list[RepositoryModel]: ...
class Agent(ABC):
"""Interface for AI agent operations."""
@abstractmethod
async def initialize(self) -> None: ...
@abstractmethod
async def run(self, user_input: str) -> str: ...
@abstractmethod
async def run_with_tools(self, user_input: str, tools: list[Any]) -> str: ...
class Workspace(ABC):
"""Interface for workspace management."""
@abstractmethod
def get_repo_path(self, repo_full_name: str) -> Any: ...
@abstractmethod
def sanitize_repo(self, repo_path: Any) -> None: ...
@abstractmethod
def clone_repo(self, repo_full_name: str, clone_url: str) -> Any: ...
class MissionBuilder(ABC):
"""Interface for mission string construction."""
@abstractmethod
def build_issue_mission(self, issue_info: dict[str, Any], branch_name: str) -> str: ...
@abstractmethod
def build_pr_mission(self, pr_info: dict[str, Any]) -> str: ...
class BranchStrategy(ABC):
"""Interface for branch creation strategy."""
@abstractmethod
async def create_or_reuse_branch(self, repo_path: Any, branch_name: str, base_branch: str | None = None) -> str: ...
+45
View File
@@ -0,0 +1,45 @@
import logging
from typing import Any, Callable
from core.agent import BaseAgent
from core.prompts import NOTIFICATION_READER_SYSTEM_PROMPT
from core.notification_tools import NotificationTools
logger: logging.Logger = logging.getLogger("agent-notification-reader")
class NotificationNoToolCalledError(Exception):
"""Raised when the Notification Reader Agent completes execution without calling any routing tool."""
pass
class NotificationReaderAgent(BaseAgent):
"""AI agent that reviews Gitea notifications and decides how to route them."""
def __init__(self, model_name: str) -> None:
super().__init__(model_name)
self.system_prompt = NOTIFICATION_READER_SYSTEM_PROMPT
async def decide_notification(
self,
mission: str,
inspection_tools: list[Callable[..., Any]],
notification_tools: NotificationTools,
) -> str:
"""Run the Notification Reader Agent and ensure a decision tool is called."""
decision_tools: list[Callable[..., Any]] = [
notification_tools.process_issue,
notification_tools.process_pr,
notification_tools.skip_notification,
]
combined_tools: list[Callable[..., Any]] = inspection_tools + decision_tools
logger.info("Running NotificationReaderAgent to decide action...")
response_text: str = await self.run_with_tools(mission, combined_tools)
if not notification_tools.tool_called:
logger.warning("NotificationReaderAgent did not call any tools!")
raise NotificationNoToolCalledError(
"NotificationReaderAgent failed to call a routing tool during execution."
)
return response_text
+68
View File
@@ -0,0 +1,68 @@
import logging
from typing import Any
logger: logging.Logger = logging.getLogger("notification-tools")
class NotificationTools:
"""Tools exposed to the Notification Reader Agent for routing decisions."""
def __init__(self) -> None:
self.tool_called: bool = False
self.action: str = "NO_ACTION"
self.arguments: dict[str, Any] = {}
def process_issue(self, owner: str, repo: str, issue_number: int, reason: str) -> str:
"""Process the notification as an issue.
Use this when a notification refers to an issue that requires agent action.
Args:
owner: The repository owner (organization).
repo: The repository name.
issue_number: The Gitea issue number.
reason: Why this notification should be processed.
"""
logger.info(f"Notification tool 'process_issue' called for {owner}/{repo}#{issue_number}: {reason}")
self.tool_called = True
self.action = "PROCESS_ISSUE"
self.arguments = {
"owner": owner,
"repo": repo,
"issue_number": issue_number,
"reason": reason,
}
return "Issue notification marked for processing."
def process_pr(self, owner: str, repo: str, pr_number: int, reason: str) -> str:
"""Process the notification as a pull request.
Use this when a notification refers to a PR that requires agent action.
Args:
owner: The repository owner (organization).
repo: The repository name.
pr_number: The Gitea PR number.
reason: Why this notification should be processed.
"""
logger.info(f"Notification tool 'process_pr' called for {owner}/{repo}#{pr_number}: {reason}")
self.tool_called = True
self.action = "PROCESS_PR"
self.arguments = {
"owner": owner,
"repo": repo,
"pr_number": pr_number,
"reason": reason,
}
return "PR notification marked for processing."
def skip_notification(self, reason: str) -> str:
"""Skip the notification and take no action.
Use this if the notification is irrelevant, already handled, or does not require agent intervention.
Args:
reason: The reason for skipping this notification.
"""
logger.info(f"Notification tool 'skip_notification' called: {reason}")
self.tool_called = True
self.action = "SKIP"
self.arguments = {"reason": reason}
return "Notification marked to be skipped."
+219
View File
@@ -0,0 +1,219 @@
"""Top-level coordinator: polls Gitea unread notifications, queues work, dispatches to agent."""
import asyncio
import logging
import datetime
import json
from pathlib import Path
from typing import Any, Optional
from core.queue import WorkQueue, WorkItem
from core.dispatcher import AgentDispatcher
from gitea.client import GiteaClient
from gitea.models import IssueModel, PullRequestModel, RepositoryModel
from gitea.tools.gitea_tools import GiteaTools
from gitea.config import AGENT_MODEL_ID, AGENT_MAX_RETRIES
from gitea.workspace import WorkspaceManager
from core.factory import AgentFactory
from core.notification_tools import NotificationTools
from core.notification_agent import NotificationNoToolCalledError
logger: logging.Logger = logging.getLogger("agent-orchestrator")
class AgentOrchestrator:
"""Top-level coordinator: polls Gitea notifications, queues work, dispatches to agent."""
def __init__(
self,
client: GiteaClient,
tools: GiteaTools,
model_name: str = AGENT_MODEL_ID,
max_retries: int = AGENT_MAX_RETRIES,
) -> None:
self._client = client
self._tools = tools
self._model_name = model_name
self._work_queue = WorkQueue()
self._dispatcher = AgentDispatcher(client, tools, model_name, max_retries)
self._notification_reader = AgentFactory.create_notification_reader_agent(model_name)
self._max_retries = max_retries
def _get_state_file_path(self) -> Path:
"""Get the path to the persistent state file."""
return Path(__file__).parent.parent / "agent_state.json"
def _read_last_checked(self) -> Optional[str]:
"""Read the last checked timestamp from persistent storage."""
state_file = self._get_state_file_path()
if state_file.exists():
try:
with open(state_file, "r") as f:
data = json.load(f)
return data.get("last_checked")
except Exception as e:
logger.warning(f"Error reading agent_state.json: {e}")
return None
def _write_last_checked(self, timestamp: str) -> None:
"""Write the last checked timestamp to persistent storage."""
state_file = self._get_state_file_path()
try:
with open(state_file, "w") as f:
json.dump({"last_checked": timestamp}, f)
except Exception as e:
logger.error(f"Error writing agent_state.json: {e}")
async def poll_and_dispatch(self) -> None:
"""Poll Gitea unread notifications, enqueue them, and dispatch to agent."""
last_checked = self._read_last_checked()
logger.info(f"Polling unread notifications since: {last_checked or 'beginning'}")
notifications = self._client.list_unread_notifications(since=last_checked)
if not notifications:
logger.info("No new notifications found.")
return
logger.info(f"Retrieved {len(notifications)} unread notifications.")
# Filter and enqueue tasks from notifications
latest_timestamp = last_checked
inspection_tools = [
self._tools.get_issue,
self._tools.get_pull_request,
self._tools.get_issue_comments,
self._tools.get_pull_request_comments,
]
for n in notifications:
notification_id = n.get("id")
subject = n.get("subject") or {}
subj_type = subject.get("type", "").lower()
subj_url = subject.get("url", "")
updated_at = n.get("updated_at")
# Track latest updated_at to advance checkpoint
if updated_at and (not latest_timestamp or updated_at > latest_timestamp):
latest_timestamp = updated_at
repo_info = n.get("repository") or {}
repo_full_name = repo_info.get("full_name", "")
if not repo_full_name or not subj_url:
continue
owner, repo_name = repo_full_name.split("/")
try:
task_number = int(subj_url.rstrip("/").split("/")[-1])
except (ValueError, IndexError):
logger.warning(f"Could not parse task number from subject URL: {subj_url}")
continue
# Run NotificationReaderAgent to pre-screen the notification
mission = (
f"Analyze Gitea notification ID {notification_id}.\n"
f"Repository: {repo_full_name}\n"
f"Subject Type: {subj_type}\n"
f"Task Number: {task_number}\n"
f"Please decide if we should process or skip this notification."
)
notification_tools = NotificationTools()
attempt = 0
attempt_limit = self._max_retries
success = False
while attempt < attempt_limit:
attempt += 1
try:
await self._notification_reader.decide_notification(
mission,
inspection_tools,
notification_tools
)
success = True
break
except NotificationNoToolCalledError as e:
logger.error(f"Notification reader error on notification {notification_id} (attempt {attempt}/{attempt_limit}): {e}")
if not success or notification_tools.action == "SKIP":
reason = notification_tools.arguments.get("reason", "Failed to call routing tool / default skip")
logger.info(f"Skipping notification {notification_id} for {repo_full_name}#{task_number}. Reason: {reason}")
if notification_id is not None:
self._client.mark_notification_as_read(notification_id)
logger.info(f"Marked skipped Gitea notification thread {notification_id} as read.")
continue
# Route based on decided action
if notification_tools.action == "PROCESS_ISSUE":
try:
issue = self._client.get_issue(owner, repo_name, task_number)
if issue.repository is None:
issue = issue.model_copy(update={"repository": RepositoryModel(**repo_info)})
item = WorkItem(
repo_full_name=repo_full_name,
task_type="issue",
task_number=task_number,
task_info=issue,
notification_id=notification_id,
priority=0
)
self._work_queue.enqueue(item)
except Exception as e:
logger.error(f"Failed to fetch issue #{task_number} for notification: {e}")
elif notification_tools.action == "PROCESS_PR":
try:
pr = self._client.get_pull_request(owner, repo_name, task_number)
if pr.repository is None:
pr = pr.model_copy(update={"repository": RepositoryModel(**repo_info)})
item = WorkItem(
repo_full_name=repo_full_name,
task_type="pr",
task_number=task_number,
task_info=pr,
notification_id=notification_id,
priority=0
)
self._work_queue.enqueue(item)
except Exception as e:
logger.error(f"Failed to fetch PR #{task_number} for notification: {e}")
# Process enqueued work
if not self._work_queue.is_empty:
await self._process_work()
# Update last checked timestamp checkpoint
if latest_timestamp:
self._write_last_checked(latest_timestamp)
async def _process_work(self) -> None:
"""Process all queued work, repo by repo, then mark notifications as read."""
while not self._work_queue.is_empty:
repo = self._work_queue.get_next_repo()
if not repo:
break
work_items = self._work_queue.get_repo_work(repo)
self._work_queue.remove_repo_work(repo)
# Ensure the workspace repository is cloned and sanitized
workspace = WorkspaceManager()
repo_path = workspace.get_repo_path(repo)
if not repo_path.exists():
workspace.clone_repo(repo)
logger.info(f"Cloned {repo} to {repo_path}")
else:
workspace.sanitize_repo(repo, repo_path)
logger.info(f"Sanitized existing repo at {repo_path}")
logger.info(f"Dispatching {len(work_items)} tasks for {repo}")
results = await self._dispatcher.dispatch(repo, work_items)
for i, result in enumerate(results):
item = work_items[i]
logger.info(f"Completed {item.task_type} #{item.task_number}: {result[:200]}")
if item.notification_id is not None:
self._client.mark_notification_as_read(item.notification_id)
logger.info(f"Marked Gitea notification thread {item.notification_id} as read.")
+13
View File
@@ -0,0 +1,13 @@
import logging
from core.agent import BaseAgent
from .coding_prompt import CODING_AGENT_SYSTEM_PROMPT
logger: logging.Logger = logging.getLogger("agent-planning")
class PlanningAgent(BaseAgent):
"""AI agent that analyzes a PR/issue and builds an implementation plan."""
def __init__(self, model_name: str) -> None:
super().__init__(model_name)
self.system_prompt = CODING_AGENT_SYSTEM_PROMPT
+20
View File
@@ -0,0 +1,20 @@
CAVEMAN_PROMPT = """
CAVEMAN SYSTEM PROMPT:
You are Caveman Agent.
Goal: Minimal tokens. Efficient work.
Rules:
1. Use fewest words possible.
2. Drop non-essential words (e.g., "the", "is", "a").
3. Use acronyms (PR, issue, repo, etc.).
4. If task simple, answer direct.
5. No preamble. No polite talk. Just work.
Example:
User: What is the status of PR #1?
Caveman: PR #1 open. Reviewing...
Follow instructions. Use tools. Execute tasks.
No extra chatter.
"""
+51
View File
@@ -0,0 +1,51 @@
"""System prompts and configurations for agents."""
COORDINATOR_SYSTEM_PROMPT: str = """
You are an AI Coordinator. Your job is to analyze Gitea issues, read the conversation history, and determine the next action for the agent.
Based on the conversation state, you must choose and call exactly one of the following tools:
1. `propose_plan`: Choose this if code changes are needed to resolve the issue, and either:
- No plan has been proposed yet by the AI agent.
- Or a plan was proposed, but the human replied with feedback, corrections, or requests for changes, so we need to propose a revised plan.
You must provide a detailed, step-by-step implementation plan (listing files to modify/create, specific changes to make, verification/test commands).
2. `start_implementation`: Choose this if:
- A plan was previously proposed AND the human has clearly replied with approval/greenlight/go-ahead (e.g., "yes", "looks good", "ok", "go ahead", etc.).
- Or there is an existing WIP PR or a PR with requested changes, and we need to resume implementing the changes.
You must extract/summarize the approved plan, incorporating any human feedback.
3. `answer_question`: Choose this if the issue is just a question or request for information (no code changes needed), and either:
- No answer has been provided yet by the AI agent.
- Or the agent answered, but the human replied with follow-up questions/clarifications.
Provide a clear, helpful response.
4. `close_issue`: Choose this ONLY if the AI agent previously answered a question AND the human has explicitly replied with a message confirming they are fully satisfied or explicitly instructing the agent to close the issue (e.g., "thanks, this answers my question", "looks good, you can close this", "close it"). If the human's response is a follow-up question, is ambiguous, or does not explicitly approve closing, you must NOT call this tool (call `answer_question` or `take_no_action` instead).
Provide a polite closing comment.
5. `take_no_action`: Choose this if the issue is already resolved, or if we cannot proceed for another reason.
CRITICAL INSTRUCTIONS:
- You must call EXACTLY one tool. Do not guess, and do not output raw text instead of calling a tool.
- The `plan`, `answer`, or `comment` argument you pass to the tool will be posted directly to Gitea. DO NOT include your thought process, reasoning, or internal details in those arguments. Keep them concise and professional.
"""
NOTIFICATION_READER_SYSTEM_PROMPT: str = """
You are a Gitea Notification Reader Agent. Your job is to analyze incoming Gitea notifications and determine how they should be routed.
Based on the notification subject, details, and conversation comments (if retrieved), you must choose and call exactly one of the following tools:
1. `process_issue`: Choose this if the notification refers to a Gitea issue that requires active intervention, planning, implementation, or answering a question by the AI agent.
2. `process_pr`: Choose this if the notification refers to a Gitea Pull Request that requires active intervention, code reviews, updates, or merging by the AI agent.
3. `skip_notification`: Choose this if:
- The notification is irrelevant or does not require AI agent intervention.
- It is a notification about an action taken by the AI agent itself (e.g. self-assigned, self-commented, self-opened).
- The discussion is closed or resolved, or the notification is just informational (e.g. a simple status update that needs no reply).
- You are unsure or think it does not fit the agent's scope. You must provide a clear reason for skipping.
CRITICAL INSTRUCTIONS:
- You must call EXACTLY one tool. Do not guess, and do not output raw text instead of calling a tool.
- You can use the provided inspection tools (like get_issue, get_pull_request, get_issue_comments, get_pull_request_comments) to gather more details if the basic notification metadata is insufficient to make a decision.
"""
+61
View File
@@ -0,0 +1,61 @@
import logging
from pydantic import BaseModel
from typing import Any, Optional
from gitea.models import IssueModel, PullRequestModel
logger: logging.Logger = logging.getLogger("work-queue")
class WorkItem(BaseModel):
repo_full_name: str
task_type: str # 'issue' or 'pr'
task_number: int
task_info: IssueModel | PullRequestModel
notification_id: Optional[int] = None
priority: int = 0
class WorkQueue:
"""Thread-safe work queue grouped by repo."""
def __init__(self) -> None:
self._queue: list[WorkItem] = []
self._enqueued_repos: set[str] = set()
def enqueue(self, item: WorkItem) -> None:
self._queue.append(item)
self._enqueued_repos.add(item.repo_full_name)
logger.info(f"Enqueued work item: {item.task_type} #{item.task_number} for {item.repo_full_name}")
def enqueue_batch(self, items: list[WorkItem]) -> None:
for item in items:
self.enqueue(item)
def get_repo_work(self, repo: str) -> list[WorkItem]:
"""Get all work items for a specific repo."""
items: list[WorkItem] = [
item for item in self._queue if item.repo_full_name == repo
]
logger.info(f"Retrieved {len(items)} work items for repository: {repo}")
return items
def remove_repo_work(self, repo: str) -> None:
"""Remove all work items for a specific repo."""
self._queue = [
item for item in self._queue if item.repo_full_name != repo
]
self._enqueued_repos.discard(repo)
logger.info(f"Removed all work items for repository: {repo}")
def get_next_repo(self) -> str | None:
"""Get the next repo with work, or None if empty."""
if not self._enqueued_repos:
return None
return next(iter(self._enqueued_repos))
@property
def is_empty(self) -> bool:
return len(self._queue) == 0
def __len__(self) -> int:
return len(self._queue)