Add .gitignore and pyproject.toml (#1)
### Findings and Changes
#### Changes:
- **Added **: Included a standard Python to avoid tracking unnecessary files (e.g., , , ).
- **Added **: Prepared the project for better dependency management.
- **Enhanced Gitea Tools**:
- Implemented in .
- Implemented (via ) in .
#### Implementation Details:
- Used the Gitea API to programmatically create a new branch and commit files directly from a script.
- Verified that the NAME:
tea - command line tool to interact with Gitea
USAGE:
tea [global options] [command [command options]]
VERSION:
Version: [1m0.14.1[0m golang: 1.26.3 go-sdk: v0.25.1
DESCRIPTION:
tea is a productivity helper for Gitea. It can be used to manage most entities on
one or multiple Gitea instances & provides local helpers like 'tea pr checkout'.
tea tries to make use of context provided by the repository in $PWD if available.
tea works best in a upstream/fork workflow, when the local main branch tracks the
upstream repo. tea assumes that local git state is published on the remote before
doing operations with tea. Configuration is persisted in $XDG_CONFIG_HOME/tea.
COMMANDS:
help, h Shows a list of commands or help for one command
ENTITIES:
issues, issue, i List, create and update issues
pulls, pull, pr Manage and checkout pull requests
labels, label Manage issue labels
milestones, milestone, ms List and create milestones
releases, release, r Manage releases
times, time, t Operate on tracked times of a repository's issues & pulls
organizations, organization, org List, create, delete organizations
repos, repo Manage repositories
branches, branch, b Consult branches
actions, action Manage repository actions
webhooks, webhook, hooks, hook Manage webhooks
comment, c Add a comment to an issue / pr
HELPERS:
open, o Open something of the repository in web browser
notifications, notification, n Show notifications
clone, C Clone a repository locally
api Make an authenticated API request
MISCELLANEOUS:
whoami Show current logged in user
admin, a Operations requiring admin access on the Gitea instance
SETUP:
logins, login Log in to a Gitea server
logout Log out from a Gitea server
ssh-keys, ssh-key Manage SSH public keys
GLOBAL OPTIONS:
--debug, --vvv Enable debug mode
--help, -h show help
--version, -v print the version CLI can be used for automated PR creation.
- Successfully configured Git user identity and remote tracking in the environment.
---------
Co-authored-by: Michael <michael@example.com>
Reviewed-on: #1
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Core packages."""
|
||||
@@ -0,0 +1,96 @@
|
||||
import asyncio
|
||||
import lmstudio as lms
|
||||
from typing import Any, Callable
|
||||
from .prompt import CAVEMAN_PROMPT
|
||||
|
||||
|
||||
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 CavemanAgent:
|
||||
"""Caveman AI agent - minimal token usage variant."""
|
||||
|
||||
def __init__(self, model_name: str) -> None:
|
||||
self.model_name: str = model_name
|
||||
self.model: Any | None = None
|
||||
self.system_prompt: str = CAVEMAN_PROMPT
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the LM Studio model."""
|
||||
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:
|
||||
response = await self.model.respond(user_input, messages=messages)
|
||||
return response
|
||||
except Exception as 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()
|
||||
result: lms.ActResult = self.model.act(user_input, tools=tools, on_message=capture)
|
||||
response: str = capture.full_response
|
||||
if not response or response == "No response captured.":
|
||||
return f"Act completed with {result.rounds} rounds but no response captured."
|
||||
return response
|
||||
except Exception as e:
|
||||
return f"Error in agent tool execution: {str(e)}"
|
||||
@@ -0,0 +1,101 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import lmstudio as lms
|
||||
from typing import Any, Callable
|
||||
from .prompt import CAVEMAN_PROMPT
|
||||
from .coding_prompt import CODING_AGENT_SYSTEM_PROMPT
|
||||
|
||||
|
||||
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 CodingAgent:
|
||||
"""AI agent that interacts with LMStudio models and tools."""
|
||||
|
||||
def __init__(self, model_name: str) -> None:
|
||||
self.model_name: str = model_name
|
||||
self.model: Any | None = None
|
||||
self.system_prompt: str = CODING_AGENT_SYSTEM_PROMPT
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the LM Studio model."""
|
||||
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:
|
||||
response = await self.model.respond(user_input, messages=messages)
|
||||
return response
|
||||
except Exception as e:
|
||||
return f"Error in agent execution: {str(e)}"
|
||||
|
||||
async def run_with_tools(self, user_input: str, tools: list[Callable[..., 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: logging.Logger = logging.getLogger("agent-coding")
|
||||
logger.info(f"Calling LMStudio act() with {len(tools)} tools...")
|
||||
result: lms.ActResult = self.model.act(user_input, tools=tools, on_message=capture)
|
||||
logger.info(f"act() returned: {result}")
|
||||
response: str = capture.full_response
|
||||
if not response or response == "No response captured.":
|
||||
return f"Act completed with {result.rounds} rounds but no response captured."
|
||||
return response
|
||||
except Exception as e:
|
||||
return f"Error in agent tool execution: {str(e)}"
|
||||
@@ -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 PR**: Always create a PR linking the issue using the dedicated `create_pull_request` tool:
|
||||
Do NOT use Gitea's `tea` CLI or GitHub's `gh` CLI via `run_command` (they run interactively and will freeze/hang indefinitely).
|
||||
Call `create_pull_request` directly.
|
||||
where the PR description follows the template below.
|
||||
|
||||
### 📋 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.
|
||||
"""
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Dispatches work to a single CodingAgent, one repo at a time."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from core.coding_agent import CodingAgent
|
||||
from core.queue import WorkItem
|
||||
from gitea.tools.coding_tools import CodingTools
|
||||
from gitea.tools.gitea_tools import GiteaTools
|
||||
from gitea.client import GiteaClient
|
||||
from core.coding_prompt import CODING_AGENT_SYSTEM_PROMPT
|
||||
from gitea.config import AGENT_MODEL_ID
|
||||
from gitea.workspace import WorkspaceManager
|
||||
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.compile(
|
||||
rf"\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+#(\d+)\b",
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
class AgentDispatcher:
|
||||
"""Dispatches work to a single CodingAgent, 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
|
||||
|
||||
def _find_pr_for_issue(self, 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 = self._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(self, 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(self, 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.
|
||||
The agent itself embeds the marker when it needs human input.
|
||||
"""
|
||||
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
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
repo: str,
|
||||
work_items: list[WorkItem],
|
||||
) -> list[str]:
|
||||
"""Dispatch all work for a single repo to a fresh agent, then discard it."""
|
||||
workspace = WorkspaceManager()
|
||||
repo_path = workspace.get_repo_path(repo)
|
||||
coding_tools = CodingTools(str(repo_path))
|
||||
|
||||
planning_tools: list[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,
|
||||
coding_tools.list_files,
|
||||
coding_tools.read_file,
|
||||
coding_tools.grep_search,
|
||||
coding_tools.get_working_directory,
|
||||
coding_tools.run_command,
|
||||
]
|
||||
|
||||
coding_tools_list: list[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.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,
|
||||
coding_tools.list_files,
|
||||
coding_tools.read_file,
|
||||
coding_tools.write_file,
|
||||
coding_tools.edit_file,
|
||||
coding_tools.run_command,
|
||||
coding_tools.grep_search,
|
||||
coding_tools.get_working_directory,
|
||||
]
|
||||
|
||||
results: list[str] = []
|
||||
|
||||
import os
|
||||
original_cwd = os.getcwd()
|
||||
changed_dir = False
|
||||
if os.path.isdir(str(repo_path)):
|
||||
os.chdir(str(repo_path))
|
||||
changed_dir = True
|
||||
try:
|
||||
for item in work_items:
|
||||
if item.task_type == "issue":
|
||||
existing_pr = self._find_pr_for_issue(repo, item.task_number)
|
||||
if existing_pr:
|
||||
logger.info(f"Issue #{item.task_number} already has open PR #{existing_pr.number}. Skipping.")
|
||||
results.append(f"SKIP: A pull request (PR #{existing_pr.number}) addressing issue #{item.task_number} already exists.")
|
||||
continue
|
||||
|
||||
# Check if we're waiting for a human reply before acting
|
||||
owner, repo_name = repo.split("/")
|
||||
issue_comments = []
|
||||
try:
|
||||
issue_comments = self._client.get_issue_comments(owner, repo_name, item.task_number)
|
||||
except Exception:
|
||||
pass
|
||||
if self._is_awaiting_reply(issue_comments):
|
||||
logger.info(f"Issue #{item.task_number}: agent asked a question and is awaiting a human reply. Skipping.")
|
||||
results.append(f"SKIP: Awaiting human reply on issue #{item.task_number}.")
|
||||
continue
|
||||
|
||||
elif item.task_type == "pr":
|
||||
# Check if we're waiting for a human reply before acting on a PR
|
||||
owner, repo_name = repo.split("/")
|
||||
pr_comments = []
|
||||
try:
|
||||
pr_comments = self._client.get_pull_request_comments(owner, repo_name, item.task_number)
|
||||
except Exception:
|
||||
pass
|
||||
if self._is_awaiting_reply(pr_comments):
|
||||
logger.info(f"PR #{item.task_number}: agent asked a question and is awaiting a human reply. Skipping.")
|
||||
results.append(f"SKIP: Awaiting human reply on PR #{item.task_number}.")
|
||||
continue
|
||||
|
||||
for attempt in range(1, self._max_retries + 1):
|
||||
try:
|
||||
if item.task_type == "issue":
|
||||
base_mission = self._build_issue_mission(item)
|
||||
else:
|
||||
base_mission = self._build_pr_mission(item)
|
||||
|
||||
if base_mission.startswith("SKIP:"):
|
||||
logger.info(f"Skipping task #{item.task_number}: {base_mission}")
|
||||
results.append(base_mission)
|
||||
break
|
||||
|
||||
# Step 1: Planning Phase
|
||||
logger.info(f"Starting Planning Phase for {item.task_type} #{item.task_number} (attempt {attempt})")
|
||||
planning_mission = (
|
||||
f"PHASE 1: PLANNING PHASE\n\n"
|
||||
f"Your task is to analyze the repository structure and create 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. Explore the codebase using read_file, list_files, grep_search, or run_command (for read-only queries like find/grep).\n"
|
||||
f"3. Output your final plan clearly, describing the exact changes to be made and which files to modify.\n"
|
||||
)
|
||||
planning_agent = CodingAgent(self._model_name)
|
||||
plan = await planning_agent.run_with_tools(planning_mission, planning_tools)
|
||||
logger.info(f"Generated Plan:\n{plan}")
|
||||
|
||||
# Step 2: Coding Phase
|
||||
logger.info(f"Starting Execution/Coding Phase for {item.task_type} #{item.task_number} (attempt {attempt})")
|
||||
coding_mission = (
|
||||
f"PHASE 2: EXECUTION/CODING PHASE\n\n"
|
||||
f"You must now implement the changes based on the following plan generated in Phase 1:\n"
|
||||
f"--- PLAN ---\n{plan}\n--- PLAN END ---\n\n"
|
||||
f"Original Mission details:\n{base_mission}\n\n"
|
||||
f"Follow the repository workflow to make the changes, verify them, commit, push, and create a PR.\n"
|
||||
)
|
||||
coding_agent = CodingAgent(self._model_name)
|
||||
response = await coding_agent.run_with_tools(coding_mission, coding_tools_list)
|
||||
logger.info(f"Agent response for {item.task_type} #{item.task_number}: {response}")
|
||||
results.append(response)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing {item.task_type} #{item.task_number} (attempt {attempt}/{self._max_retries}): {e}")
|
||||
if attempt == self._max_retries:
|
||||
results.append(f"FAILED after {self._max_retries} attempts: {str(e)}")
|
||||
finally:
|
||||
if changed_dir:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
return results
|
||||
|
||||
def _build_issue_mission(self, item: WorkItem) -> str:
|
||||
issue_info = item.task_info
|
||||
assert isinstance(issue_info, IssueModel)
|
||||
|
||||
|
||||
repo_full_name: str = item.repo_full_name
|
||||
issue_number: int = item.task_number
|
||||
|
||||
issue_body: str = issue_info.body or "No description provided."
|
||||
issue_labels: list[str] = [lbl.name for lbl in issue_info.labels]
|
||||
issue_user: str = issue_info.user.login if issue_info.user else "unknown"
|
||||
issue_created: str = issue_info.created_at or "unknown"
|
||||
title: str = issue_info.title
|
||||
|
||||
|
||||
owner: str = repo_full_name.split("/")[0]
|
||||
repo_name: str = repo_full_name.split("/")[1]
|
||||
|
||||
comments: list[CommentModel] = []
|
||||
try:
|
||||
comments = self._client.get_issue_comments(owner, repo_name, issue_number)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
labels_str: str = f"Labels: {', '.join(issue_labels)}" if issue_labels else "Labels: none"
|
||||
comments_str: str = "\n".join([
|
||||
f"- @{c.user.login} ({c.created_at}): {c.body}"
|
||||
for c in comments
|
||||
]) if comments else "No comments yet."
|
||||
|
||||
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: str = f"fix/issue-{issue_number}-{desc_suffix}"
|
||||
|
||||
workspace = WorkspaceManager()
|
||||
repo_path = workspace.get_repo_path(repo_full_name)
|
||||
|
||||
return (
|
||||
f"Your mission is to resolve issue #{issue_number} in {repo_full_name}.\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 '{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."
|
||||
)
|
||||
|
||||
def _build_pr_mission(self, item: WorkItem) -> str:
|
||||
pr_info = item.task_info
|
||||
assert isinstance(pr_info, PullRequestModel)
|
||||
|
||||
|
||||
repo_full_name: str = item.repo_full_name
|
||||
pr_number: int = item.task_number
|
||||
|
||||
owner: str = repo_full_name.split("/")[0]
|
||||
repo_name: str = repo_full_name.split("/")[1]
|
||||
|
||||
pr_model = self._client.get_pull_request(owner, repo_name, pr_number)
|
||||
pr_details: str = pr_model.model_dump_json(indent=2)
|
||||
pr_diff: str = ""
|
||||
try:
|
||||
pr_diff = self._client.get_pull_request_diff(owner, 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(owner, repo_name, pr_number)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
files_summary: str = "\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(owner, 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(owner, repo_name, pr_number)
|
||||
if not isinstance(reviews, list):
|
||||
reviews = []
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ai_username = "meeks-ai"
|
||||
try:
|
||||
user = self._client.get_authenticated_user()
|
||||
if user:
|
||||
ai_username = user.login
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Create a combined, sorted timeline of timeline comments and reviews
|
||||
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 == 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 == ai_username
|
||||
})
|
||||
|
||||
timeline.sort(key=lambda x: x["timestamp"])
|
||||
|
||||
last_action_by_ai = False
|
||||
if timeline:
|
||||
last_action_by_ai = timeline[-1]["by_ai"]
|
||||
|
||||
pr_author: str = pr_info.user.login if pr_info.user else "unknown"
|
||||
is_own_pr = (pr_author == ai_username)
|
||||
|
||||
# Skip if the latest action is already by AI (waiting for human turn)
|
||||
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: str = "\n".join([
|
||||
f"- @{c.user.login} ({c.created_at}): {c.body}"
|
||||
for c in comments
|
||||
]) if comments else "No comments yet."
|
||||
|
||||
reviews_str: 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 = self._find_issues_for_pr(pr_body)
|
||||
if linked_issues:
|
||||
issues_details = []
|
||||
for issue_num in linked_issues:
|
||||
try:
|
||||
issue = self._client.get_issue(owner, repo_name, issue_num)
|
||||
issue_comments = self._client.get_issue_comments(owner, 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)
|
||||
|
||||
workspace = WorkspaceManager()
|
||||
repo_path = workspace.get_repo_path(repo_full_name)
|
||||
pr_head_branch: str = pr_info.head.get('ref', 'unknown') if pr_info.head else "unknown"
|
||||
pr_base_branch: str = pr_info.base.get('ref', 'unknown') if pr_info.base else "unknown"
|
||||
pr_state: str = pr_info.state
|
||||
pr_created: str = pr_info.created_at or "unknown"
|
||||
|
||||
# Dynamically determine instructions based on ownership/comments
|
||||
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 '{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 '{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 {repo_full_name}.\n\n"
|
||||
f"PR: {pr_info.title}\n"
|
||||
f"Author: @{pr_author}\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}"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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 gitea.workspace import WorkspaceManager
|
||||
|
||||
|
||||
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:
|
||||
return CodingAgent(model_name)
|
||||
|
||||
@staticmethod
|
||||
def create_caveman_agent(model_name: str) -> CavemanAgent:
|
||||
return CavemanAgent(model_name)
|
||||
|
||||
|
||||
class WorkspaceFactory:
|
||||
"""Factory for creating workspace manager instances."""
|
||||
|
||||
@staticmethod
|
||||
def create_workspace() -> WorkspaceManager:
|
||||
return WorkspaceManager()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""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 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: ...
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Top-level coordinator: polls Gitea, queues work, dispatches to agent."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from core.queue import WorkQueue, WorkItem
|
||||
from core.dispatcher import AgentDispatcher
|
||||
from gitea.client import GiteaClient
|
||||
from gitea.models import IssueModel, PullRequestModel
|
||||
from gitea.tools.gitea_tools import GiteaTools
|
||||
from gitea.config import AGENT_MODEL_ID, AGENT_MAX_RETRIES
|
||||
from gitea.workspace import WorkspaceManager
|
||||
|
||||
logger: logging.Logger = logging.getLogger("agent-orchestrator")
|
||||
|
||||
|
||||
class AgentOrchestrator:
|
||||
"""Top-level coordinator: polls Gitea, 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)
|
||||
|
||||
async def poll_and_dispatch(self) -> None:
|
||||
"""Poll Gitea for tasks, enqueue them, and dispatch to agent."""
|
||||
issues: list[IssueModel] = self._client.list_assigned_issues()
|
||||
prs: list[PullRequestModel] = self._client.list_assigned_pull_requests()
|
||||
|
||||
if issues:
|
||||
logger.info(f"Found {len(issues)} assigned issues")
|
||||
self._enqueue_tasks("issue", issues)
|
||||
else:
|
||||
logger.info("No assigned issues found.")
|
||||
|
||||
if prs:
|
||||
logger.info(f"Found {len(prs)} assigned PRs")
|
||||
self._enqueue_tasks("pr", prs)
|
||||
else:
|
||||
logger.info("No assigned PRs found.")
|
||||
|
||||
if not self._work_queue.is_empty:
|
||||
await self._process_work()
|
||||
|
||||
def _enqueue_tasks(self, task_type: str, tasks: list[IssueModel] | list[PullRequestModel]) -> None:
|
||||
for task in tasks:
|
||||
repo_full_name: str | None = task.repository.full_name if task.repository else None
|
||||
task_number: int = task.number
|
||||
if not repo_full_name or not task_number:
|
||||
continue
|
||||
|
||||
item = WorkItem(
|
||||
repo_full_name=repo_full_name,
|
||||
task_type=task_type,
|
||||
task_number=task_number,
|
||||
task_info=task,
|
||||
priority=0,
|
||||
)
|
||||
self._work_queue.enqueue(item)
|
||||
logger.info(f"Enqueued {task_type} #{task_number} from {repo_full_name}")
|
||||
|
||||
async def _process_work(self) -> None:
|
||||
"""Process all queued work, repo by repo."""
|
||||
while not self._work_queue.is_empty:
|
||||
repo: str | None = self._work_queue.get_next_repo()
|
||||
if not repo:
|
||||
break
|
||||
|
||||
work_items: list[WorkItem] = 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: list[str] = 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]}")
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -0,0 +1,54 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Any
|
||||
from gitea.models import IssueModel, PullRequestModel
|
||||
|
||||
|
||||
class WorkItem(BaseModel):
|
||||
repo_full_name: str
|
||||
task_type: str # 'issue' or 'pr'
|
||||
task_number: int
|
||||
task_info: IssueModel | PullRequestModel
|
||||
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)
|
||||
|
||||
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
|
||||
]
|
||||
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)
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user