struff
This commit is contained in:
parent
3ca6b4d5f8
commit
07176f9b79
8 changed files with 749 additions and 57 deletions
|
|
@ -11,6 +11,35 @@ on:
|
||||||
- web_v2
|
- web_v2
|
||||||
- backend
|
- backend
|
||||||
- common
|
- common
|
||||||
|
llm_model:
|
||||||
|
description: 'LLM Model'
|
||||||
|
required: true
|
||||||
|
default: 'auto'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- auto
|
||||||
|
- composer-1
|
||||||
|
- sonnet-4.5
|
||||||
|
- sonnet-4.5-thinking
|
||||||
|
- gemini-3-pro
|
||||||
|
- gpt-5
|
||||||
|
- gpt-5.1
|
||||||
|
- gpt-5-high
|
||||||
|
- gpt-5.1-high
|
||||||
|
- gpt-5-codex
|
||||||
|
- gpt-5-codex-high
|
||||||
|
- gpt-5.1-codex
|
||||||
|
- gpt-5.1-codex-high
|
||||||
|
- opus-4.1
|
||||||
|
- grok
|
||||||
|
commit_mode:
|
||||||
|
description: 'Commit mode: master (direct commit with PR fallback) or pr (always create PR)'
|
||||||
|
required: false
|
||||||
|
default: 'master'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- master
|
||||||
|
- pr
|
||||||
schedule:
|
schedule:
|
||||||
# Run every 30 minutes
|
# Run every 30 minutes
|
||||||
- cron: '*/20 * * * *'
|
- cron: '*/20 * * * *'
|
||||||
|
|
@ -76,11 +105,13 @@ jobs:
|
||||||
- name: Run Development Agent
|
- name: Run Development Agent
|
||||||
env:
|
env:
|
||||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||||
CURSOR_MODEL: ${{ secrets.CURSOR_MODEL }}
|
CURSOR_MODEL: ${{ inputs.llm_model || secrets.CURSOR_MODEL }}
|
||||||
PROJECT_ROOT: ${{ github.workspace }}
|
PROJECT_ROOT: ${{ github.workspace }}
|
||||||
MAX_ITERATIONS: 10
|
MAX_ITERATIONS: 10
|
||||||
MAX_RETRIES: 3
|
MAX_RETRIES: 3
|
||||||
TIMEOUT_HOURS: 6
|
TIMEOUT_HOURS: 6
|
||||||
|
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||||
|
COMMIT_MODE: ${{ inputs.commit_mode || 'master' }}
|
||||||
run: |
|
run: |
|
||||||
# Verify cursor-agent is accessible
|
# Verify cursor-agent is accessible
|
||||||
echo "Current PATH: $PATH"
|
echo "Current PATH: $PATH"
|
||||||
|
|
|
||||||
86
.forgejo/workflows/agent-documentation.yml
Normal file
86
.forgejo/workflows/agent-documentation.yml
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
name: AI Agent - Documentation Update
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
component:
|
||||||
|
description: 'Component to document'
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- web_v2
|
||||||
|
- backend
|
||||||
|
- common
|
||||||
|
llm_model:
|
||||||
|
description: 'LLM Model'
|
||||||
|
required: true
|
||||||
|
default: 'auto'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- auto
|
||||||
|
- composer-1
|
||||||
|
- sonnet-4.5
|
||||||
|
- sonnet-4.5-thinking
|
||||||
|
- gemini-3-pro
|
||||||
|
- gpt-5
|
||||||
|
- gpt-5.1
|
||||||
|
- gpt-5-high
|
||||||
|
- gpt-5.1-high
|
||||||
|
- gpt-5-codex
|
||||||
|
- gpt-5-codex-high
|
||||||
|
- gpt-5.1-codex
|
||||||
|
- gpt-5.1-codex-high
|
||||||
|
- opus-4.1
|
||||||
|
- grok
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-docs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: code.mnemo-cards.online/cinnabarflower/cards/agent:latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Configure Git
|
||||||
|
run: |
|
||||||
|
git config --global user.name "AI Agent"
|
||||||
|
git config --global user.email "ai-agent@mnemo-cards.com"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: |
|
||||||
|
COMPONENT="${{ inputs.component }}"
|
||||||
|
if [ "$COMPONENT" = "web_v2" ]; then
|
||||||
|
cd mnemo_cards_web_v2
|
||||||
|
flutter pub get
|
||||||
|
elif [ "$COMPONENT" = "backend" ]; then
|
||||||
|
cd mnemo_cards_backend
|
||||||
|
dart pub get
|
||||||
|
elif [ "$COMPONENT" = "common" ]; then
|
||||||
|
cd mnemo_cards_common
|
||||||
|
dart pub get
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run Documentation Agent
|
||||||
|
env:
|
||||||
|
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||||
|
CURSOR_MODEL: ${{ inputs.llm_model }}
|
||||||
|
PROJECT_ROOT: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
# Verify cursor-agent is accessible
|
||||||
|
if ! command -v cursor-agent &> /dev/null; then
|
||||||
|
echo "❌ cursor-agent not found in PATH"
|
||||||
|
# Try to find it in common locations
|
||||||
|
if [ -f "$HOME/.local/bin/cursor-agent" ]; then
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
elif [ -f "$HOME/.cursor/bin/cursor-agent" ]; then
|
||||||
|
export PATH="$HOME/.cursor/bin:$PATH"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd ${{ github.workspace }}
|
||||||
|
python3 tools/agent/doc_updater.py ${{ inputs.component }} --model ${{ inputs.llm_model }}
|
||||||
|
|
@ -11,6 +11,27 @@ on:
|
||||||
- web_v2
|
- web_v2
|
||||||
- backend
|
- backend
|
||||||
- common
|
- common
|
||||||
|
llm_model:
|
||||||
|
description: 'LLM Model'
|
||||||
|
required: true
|
||||||
|
default: 'auto'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- auto
|
||||||
|
- composer-1
|
||||||
|
- sonnet-4.5
|
||||||
|
- sonnet-4.5-thinking
|
||||||
|
- gemini-3-pro
|
||||||
|
- gpt-5
|
||||||
|
- gpt-5.1
|
||||||
|
- gpt-5-high
|
||||||
|
- gpt-5.1-high
|
||||||
|
- gpt-5-codex
|
||||||
|
- gpt-5-codex-high
|
||||||
|
- gpt-5.1-codex
|
||||||
|
- gpt-5.1-codex-high
|
||||||
|
- opus-4.1
|
||||||
|
- grok
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 */4 * * *'
|
- cron: '0 */4 * * *'
|
||||||
|
|
||||||
|
|
@ -70,7 +91,7 @@ jobs:
|
||||||
- name: Run Planning Agent
|
- name: Run Planning Agent
|
||||||
env:
|
env:
|
||||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||||
CURSOR_MODEL: ${{ secrets.CURSOR_MODEL }}
|
CURSOR_MODEL: ${{ inputs.llm_model || secrets.CURSOR_MODEL }}
|
||||||
PROJECT_ROOT: ${{ github.workspace }}
|
PROJECT_ROOT: ${{ github.workspace }}
|
||||||
MAX_ITERATIONS: 10
|
MAX_ITERATIONS: 10
|
||||||
PATH: /usr/bin:/bin:$HOME/.local/bin:$HOME/.cursor/bin:${{ env.PATH }}
|
PATH: /usr/bin:/bin:$HOME/.local/bin:$HOME/.cursor/bin:${{ env.PATH }}
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,46 @@ You are an autonomous AI software engineer working on the mnemo_cards project. Y
|
||||||
## Project Context
|
## Project Context
|
||||||
|
|
||||||
This is a language learning application consisting of multiple components:
|
This is a language learning application consisting of multiple components:
|
||||||
- **mnemo_cards_web_v2**: Flutter web frontend (main user interface)
|
- **mnemo_cards_web_v2**: Flutter web frontend (main user interface). **WEB ONLY**.
|
||||||
- **mnemo_cards_backend**: Dart backend server (API, data storage)
|
- **mnemo_cards_backend**: Dart backend server (API, data storage)
|
||||||
- **mnemo_cards_common**: Shared code between frontend and backend
|
- **mnemo_cards_common**: Shared code between frontend and backend
|
||||||
|
|
||||||
|
## Autonomous Workflow Rules
|
||||||
|
|
||||||
|
### 1. Operating Mode
|
||||||
|
- **Act as an IMPLEMENTER**: Execute changes directly. Do not ask for confirmation unless a critical "Denylisted" action is required.
|
||||||
|
- **State Files**: You must maintain and read from these files at the repo root:
|
||||||
|
- `project_config.md`: Goals, constraints, stack specifics. **Read First**.
|
||||||
|
- `workflow_state.md`: Volatile loop state (Plan, Next Actions, Progress). **Update Constantly**.
|
||||||
|
- `PROGRESS.md` & `TODO.md`: High-level project tracking. **Update on Completion**.
|
||||||
|
- **Context Budget**: Summarize code context. Do not paste whole files.
|
||||||
|
|
||||||
|
### 2. The Autonomous Loop
|
||||||
|
1. **Read**: Load only necessary snippets.
|
||||||
|
2. **Plan**: Update `workflow_state.md` (Plan & Next Actions).
|
||||||
|
3. **Act**:
|
||||||
|
- Apply targeted diffs.
|
||||||
|
- Run fast checks (lint, test).
|
||||||
|
4. **Verify**:
|
||||||
|
- Parse outputs.
|
||||||
|
- Update `workflow_state.md` (Progress Log).
|
||||||
|
- If failure: Analyze -> Fix -> Loop.
|
||||||
|
- **Auto-Fix**: If `mnemo_cards_web` errors occur, run `mnemo_cards_web/complete_auto_debug.sh`, wait, read report, fix, repeat.
|
||||||
|
5. **Trim**: Prune chat context. Rely on state files.
|
||||||
|
6. **Repeat** until acceptance criteria met.
|
||||||
|
|
||||||
|
### 3. Web Specifics (mnemo_cards_web_v2)
|
||||||
|
- **WEB ONLY**: This app runs ONLY on web. No mobile, no macOS.
|
||||||
|
- **Tests**: Run tests ONLY for web platform.
|
||||||
|
- **Access**: Check `.access` for credentials if needed.
|
||||||
|
|
||||||
## Your Responsibilities
|
## Your Responsibilities
|
||||||
|
|
||||||
1. **Read and understand the task** - Analyze the requirements and acceptance criteria
|
1. **Read and understand the task** - Analyze requirements from `task.md` or user input.
|
||||||
2. **Implement the solution** - Write clean, maintainable code following project conventions
|
2. **Manage State** - Create/Update `workflow_state.md`, `PROGRESS.md`, `TODO.md`.
|
||||||
3. **Write comprehensive tests** - Ensure all new code has unit tests
|
3. **Implement the solution** - Write clean code, following `project_config.md`.
|
||||||
4. **Verify your work** - Make sure tests pass and linting is clean
|
4. **Write comprehensive tests** - Unit tests are mandatory.
|
||||||
|
5. **Verify your work** - Tests, Lints, Auto-Debug.
|
||||||
|
|
||||||
## Code Standards
|
## Code Standards
|
||||||
|
|
||||||
|
|
@ -244,12 +274,11 @@ try {
|
||||||
## Git Workflow
|
## Git Workflow
|
||||||
|
|
||||||
Your commits will be automatically created. Make sure your changes are:
|
Your commits will be automatically created. Make sure your changes are:
|
||||||
- **Atomic**: One logical change per commit
|
- **Atomic**: One logical change per commit.
|
||||||
- **Complete**: All files needed for the feature
|
- **Complete**: All files needed for the feature.
|
||||||
- **Tested**: All tests pass
|
- **Tested**: All tests pass.
|
||||||
- **Clean**: Linter passes
|
- **Clean**: Linter passes.
|
||||||
- **Size-limited**: Maximum 500 changed lines per commit
|
- **Targeted**: Prefer surgical refactors over massive rewrites.
|
||||||
- If changes exceed this limit, split the work into multiple commits/tasks
|
|
||||||
|
|
||||||
## Reference Materials
|
## Reference Materials
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,9 @@ Each task should have:
|
||||||
|
|
||||||
**CRITICAL: Tasks must be small and focused. Large tasks MUST be broken down into smaller subtasks.**
|
**CRITICAL: Tasks must be small and focused. Large tasks MUST be broken down into smaller subtasks.**
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Agent Timeout**: The agent performing the task will be automatically interrupted if the task takes longer than 20 minutes.
|
||||||
|
|
||||||
- **Small tasks** (1-3 hours): Single feature or bug fix - **PREFERRED SIZE**
|
- **Small tasks** (1-3 hours): Single feature or bug fix - **PREFERRED SIZE**
|
||||||
- **Medium tasks** (4-5 hours): Feature with multiple files - **ACCEPTABLE, but prefer smaller**
|
- **Medium tasks** (4-5 hours): Feature with multiple files - **ACCEPTABLE, but prefer smaller**
|
||||||
- **Large tasks** (3+ hours): **MUST be broken down** into smaller subtasks before adding to task list
|
- **Large tasks** (3+ hours): **MUST be broken down** into smaller subtasks before adding to task list
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,13 @@ Executes a single task per run: read task -> execute -> test -> commit
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, List
|
from typing import Optional, List, Tuple
|
||||||
|
|
||||||
from config import AgentConfig, get_test_command, get_lint_command
|
from config import AgentConfig, get_test_command, get_lint_command
|
||||||
from task_manager import TaskManager, GlobalLock, Task
|
from task_manager import TaskManager, GlobalLock, Task
|
||||||
|
|
@ -293,15 +297,16 @@ class AgentOrchestrator:
|
||||||
print("⚠️ No changes to commit")
|
print("⚠️ No changes to commit")
|
||||||
|
|
||||||
# Push changes (with pull before push to avoid conflicts)
|
# Push changes (with pull before push to avoid conflicts)
|
||||||
|
# If push fails, _git_push() will automatically create a PR
|
||||||
print("\n📤 Pushing changes...")
|
print("\n📤 Pushing changes...")
|
||||||
if not self._git_push():
|
push_success = self._git_push()
|
||||||
print("⚠️ Push failed, pulling latest changes and retrying...")
|
|
||||||
# Pull latest changes before retrying push
|
if not push_success:
|
||||||
if self._git_pull():
|
print("⚠️ Push failed, attempting to create PR...")
|
||||||
print("✅ Pulled latest changes, retrying push...")
|
# _git_push() already handles PR creation, but if it still failed,
|
||||||
self._git_push()
|
# we'll continue anyway as the changes are committed locally
|
||||||
else:
|
print("⚠️ Changes are committed locally but not pushed")
|
||||||
print("❌ Failed to pull latest changes")
|
print(" You may need to manually push or create a PR")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
@ -488,8 +493,363 @@ Changes:
|
||||||
# No changes or error
|
# No changes or error
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _get_git_repo_info(self) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Get repository owner and name from git remote."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "remote", "get-url", "origin"],
|
||||||
|
cwd=self.config.project_root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
remote_url = result.stdout.strip()
|
||||||
|
|
||||||
|
# Parse different URL formats:
|
||||||
|
# https://forgejo.example.com/owner/repo.git
|
||||||
|
# https://forgejo.example.com/owner/repo
|
||||||
|
# git@forgejo.example.com:owner/repo.git
|
||||||
|
# https://github.com/owner/repo.git
|
||||||
|
|
||||||
|
patterns = [
|
||||||
|
r'https?://[^/]+/([^/]+)/([^/]+?)(?:\.git)?/?$',
|
||||||
|
r'git@[^:]+:([^/]+)/([^/]+?)(?:\.git)?$',
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, remote_url)
|
||||||
|
if match:
|
||||||
|
owner = match.group(1)
|
||||||
|
repo = match.group(2)
|
||||||
|
return owner, repo
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _get_forgejo_api_url(self) -> Optional[str]:
|
||||||
|
"""Get Forgejo/GitHub API URL from git remote."""
|
||||||
|
if self.config.forgejo_api_url:
|
||||||
|
return self.config.forgejo_api_url
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "remote", "get-url", "origin"],
|
||||||
|
cwd=self.config.project_root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
remote_url = result.stdout.strip()
|
||||||
|
|
||||||
|
# Extract base URL
|
||||||
|
# https://forgejo.example.com/owner/repo.git -> https://forgejo.example.com
|
||||||
|
# https://github.com/owner/repo.git -> https://api.github.com
|
||||||
|
# git@forgejo.example.com:owner/repo.git -> https://forgejo.example.com
|
||||||
|
|
||||||
|
if 'github.com' in remote_url:
|
||||||
|
return 'https://api.github.com'
|
||||||
|
|
||||||
|
# For Forgejo, extract the base URL
|
||||||
|
match = re.search(r'https?://([^/]+)', remote_url)
|
||||||
|
if match:
|
||||||
|
base_url = f"https://{match.group(1)}"
|
||||||
|
return f"{base_url}/api/v1"
|
||||||
|
|
||||||
|
match = re.search(r'git@([^:]+)', remote_url)
|
||||||
|
if match:
|
||||||
|
base_url = f"https://{match.group(1)}"
|
||||||
|
return f"{base_url}/api/v1"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _check_existing_pr(self, branch_name: str) -> Optional[str]:
|
||||||
|
"""Check if PR already exists for this branch. Returns PR URL if found."""
|
||||||
|
if not self.config.forgejo_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
owner, repo = self._get_git_repo_info()
|
||||||
|
if not owner or not repo:
|
||||||
|
return None
|
||||||
|
|
||||||
|
api_url = self._get_forgejo_api_url()
|
||||||
|
if not api_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# List open PRs for this branch
|
||||||
|
pr_url = f"{api_url}/repos/{owner}/{repo}/pulls?head={owner}:{branch_name}&state=open"
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
pr_url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {self.config.forgejo_token}",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
prs = json.loads(response.read().decode('utf-8'))
|
||||||
|
if prs and len(prs) > 0:
|
||||||
|
return prs[0].get('html_url')
|
||||||
|
except Exception:
|
||||||
|
# If check fails, continue anyway
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _create_pull_request(self, task: Task, branch_name: str) -> bool:
|
||||||
|
"""Create a pull request via Forgejo/GitHub API."""
|
||||||
|
if not self.config.forgejo_token:
|
||||||
|
print("⚠️ FORGEJO_TOKEN not set, cannot create PR")
|
||||||
|
return False
|
||||||
|
|
||||||
|
owner, repo = self._get_git_repo_info()
|
||||||
|
if not owner or not repo:
|
||||||
|
print("⚠️ Could not determine repository owner/name from git remote")
|
||||||
|
return False
|
||||||
|
|
||||||
|
api_url = self._get_forgejo_api_url()
|
||||||
|
if not api_url:
|
||||||
|
print("⚠️ Could not determine API URL from git remote")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if PR already exists
|
||||||
|
existing_pr = self._check_existing_pr(branch_name)
|
||||||
|
if existing_pr:
|
||||||
|
print(f"✅ Pull Request already exists: {existing_pr}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Get current branch name (should match branch_name, but verify)
|
||||||
|
branch_result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
cwd=self.config.project_root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if branch_result.returncode != 0:
|
||||||
|
print("⚠️ Could not determine current branch")
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_branch = branch_result.stdout.strip()
|
||||||
|
|
||||||
|
# Use the provided branch_name, not current_branch (they should match)
|
||||||
|
if current_branch != branch_name:
|
||||||
|
print(f"⚠️ Branch mismatch: current={current_branch}, expected={branch_name}")
|
||||||
|
# Use current_branch for PR creation
|
||||||
|
branch_name = current_branch
|
||||||
|
|
||||||
|
# PR title and body
|
||||||
|
pr_title = f"feat({self.config.component}): {task.title}"
|
||||||
|
pr_body = f"""## Task: {task.id}
|
||||||
|
|
||||||
|
**Priority:** {task.priority}
|
||||||
|
|
||||||
|
### Description
|
||||||
|
{task.description}
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
This PR contains changes for task {task.id} completed by AI Agent.
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
"""
|
||||||
|
for i, criterion in enumerate(task.acceptance_criteria, 1):
|
||||||
|
pr_body += f"{i}. {criterion}\n"
|
||||||
|
|
||||||
|
pr_body += f"""
|
||||||
|
---
|
||||||
|
*Created automatically by AI Agent*
|
||||||
|
*Component: {self.config.component}*
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create PR via API
|
||||||
|
pr_url = f"{api_url}/repos/{owner}/{repo}/pulls"
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"title": pr_title,
|
||||||
|
"body": pr_body,
|
||||||
|
"head": current_branch,
|
||||||
|
"base": "master"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
pr_url,
|
||||||
|
data=json.dumps(data).encode('utf-8'),
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {self.config.forgejo_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
if response.status in [200, 201]:
|
||||||
|
result = json.loads(response.read().decode('utf-8'))
|
||||||
|
pr_number = result.get('number', 'unknown')
|
||||||
|
pr_html_url = result.get('html_url', pr_url)
|
||||||
|
print(f"✅ Created Pull Request #{pr_number}")
|
||||||
|
print(f" URL: {pr_html_url}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
error_body = response.read().decode('utf-8')
|
||||||
|
print(f"❌ Failed to create PR: HTTP {response.status}")
|
||||||
|
print(f" Response: {error_body}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
error_body = e.read().decode('utf-8')
|
||||||
|
print(f"❌ Failed to create PR: HTTP {e.code}")
|
||||||
|
print(f" Response: {error_body}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed to create PR: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _create_branch_and_pr(self, task: Task) -> bool:
|
||||||
|
"""Create a feature branch and PR for the task."""
|
||||||
|
# Get current task if not provided
|
||||||
|
if not task:
|
||||||
|
state = self.task_manager.load_state()
|
||||||
|
if not state.current_task_id:
|
||||||
|
print("⚠️ No current task ID, cannot create PR")
|
||||||
|
return False
|
||||||
|
|
||||||
|
tasks = self.task_manager.load_tasks()
|
||||||
|
task = next((t for t in tasks if t.id == state.current_task_id), None)
|
||||||
|
if not task:
|
||||||
|
print("⚠️ Current task not found, cannot create PR")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create branch name from task ID
|
||||||
|
branch_name = f"agent/{self.config.component}/{task.id}"
|
||||||
|
branch_name = re.sub(r'[^a-zA-Z0-9/-]', '-', branch_name)
|
||||||
|
|
||||||
|
# Get current branch name
|
||||||
|
branch_result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
cwd=self.config.project_root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown"
|
||||||
|
|
||||||
|
# If we're already on the target branch, just push it
|
||||||
|
if current_branch == branch_name:
|
||||||
|
print(f"✅ Already on branch: {branch_name}")
|
||||||
|
else:
|
||||||
|
# Check if branch already exists locally
|
||||||
|
check_branch = subprocess.run(
|
||||||
|
["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch_name}"],
|
||||||
|
cwd=self.config.project_root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if check_branch.returncode == 0:
|
||||||
|
# Branch exists locally, just checkout
|
||||||
|
print(f"⚠️ Branch {branch_name} already exists locally, checking out...")
|
||||||
|
branch_result = subprocess.run(
|
||||||
|
["git", "checkout", branch_name],
|
||||||
|
cwd=self.config.project_root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Create and checkout new branch
|
||||||
|
branch_result = subprocess.run(
|
||||||
|
["git", "checkout", "-b", branch_name],
|
||||||
|
cwd=self.config.project_root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if branch_result.returncode != 0:
|
||||||
|
print(f"⚠️ Failed to create/checkout branch: {branch_result.stderr}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"✅ Created/checked out branch: {branch_name}")
|
||||||
|
|
||||||
|
# Push branch to remote
|
||||||
|
push_branch_result = subprocess.run(
|
||||||
|
["git", "push", "-u", "origin", branch_name],
|
||||||
|
cwd=self.config.project_root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if push_branch_result.returncode != 0:
|
||||||
|
print(f"⚠️ Failed to push branch: {push_branch_result.stderr}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"✅ Pushed branch to remote")
|
||||||
|
|
||||||
|
# Create PR
|
||||||
|
if self._create_pull_request(task, branch_name):
|
||||||
|
print("✅ Successfully created PR")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("⚠️ Failed to create PR, but branch was pushed")
|
||||||
|
return False
|
||||||
|
|
||||||
def _git_push(self) -> bool:
|
def _git_push(self) -> bool:
|
||||||
"""Push changes to remote. Pulls first if needed to avoid conflicts."""
|
"""Push changes to remote. Behavior depends on commit_mode:
|
||||||
|
- 'pr': Always create PR (don't try to push to master)
|
||||||
|
- 'master': Try to push to master, create PR as fallback if push fails
|
||||||
|
"""
|
||||||
|
# Get current branch name
|
||||||
|
branch_result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
cwd=self.config.project_root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown"
|
||||||
|
|
||||||
|
# If commit_mode is 'pr', always create PR (don't try to push to master)
|
||||||
|
if self.config.commit_mode == "pr":
|
||||||
|
print("\n📝 Commit mode is 'pr', creating PR instead of pushing to master...")
|
||||||
|
|
||||||
|
# Get current task
|
||||||
|
state = self.task_manager.load_state()
|
||||||
|
if not state.current_task_id:
|
||||||
|
print("⚠️ No current task ID, cannot create PR")
|
||||||
|
return False
|
||||||
|
|
||||||
|
tasks = self.task_manager.load_tasks()
|
||||||
|
current_task = next((t for t in tasks if t.id == state.current_task_id), None)
|
||||||
|
if not current_task:
|
||||||
|
print("⚠️ Current task not found, cannot create PR")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If we're on master, create branch and PR
|
||||||
|
if current_branch == "master":
|
||||||
|
return self._create_branch_and_pr(current_task)
|
||||||
|
else:
|
||||||
|
# We're already on a feature branch, just push it and create PR
|
||||||
|
push_branch_result = subprocess.run(
|
||||||
|
["git", "push", "-u", "origin", current_branch],
|
||||||
|
cwd=self.config.project_root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if push_branch_result.returncode == 0:
|
||||||
|
print(f"✅ Pushed branch to remote")
|
||||||
|
if self._create_pull_request(current_task, current_branch):
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("⚠️ Failed to create PR, but branch was pushed")
|
||||||
|
return True # Still consider it success
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Push failed: {push_branch_result.stderr}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# commit_mode is 'master' - try to push to master, fallback to PR if fails
|
||||||
# First, fetch to check if there are remote changes
|
# First, fetch to check if there are remote changes
|
||||||
fetch_result = subprocess.run(
|
fetch_result = subprocess.run(
|
||||||
["git", "fetch"],
|
["git", "fetch"],
|
||||||
|
|
@ -518,41 +878,82 @@ Changes:
|
||||||
if not self._git_pull():
|
if not self._git_pull():
|
||||||
print("❌ Failed to pull, push may fail")
|
print("❌ Failed to pull, push may fail")
|
||||||
|
|
||||||
# Now try to push
|
# If we're on master, try to push directly
|
||||||
result = subprocess.run(
|
if current_branch == "master":
|
||||||
["git", "push"],
|
# Now try to push
|
||||||
cwd=self.config.project_root,
|
result = subprocess.run(
|
||||||
capture_output=True,
|
["git", "push"],
|
||||||
text=True
|
cwd=self.config.project_root,
|
||||||
)
|
capture_output=True,
|
||||||
|
text=True
|
||||||
if result.returncode == 0:
|
)
|
||||||
print("✅ Pushed to remote")
|
|
||||||
return True
|
if result.returncode == 0:
|
||||||
else:
|
print("✅ Pushed to remote")
|
||||||
# If push failed due to remote changes, try pull and push again
|
return True
|
||||||
if "fetch first" in result.stderr or "Updates were rejected" in result.stderr:
|
|
||||||
print("⚠️ Push rejected due to remote changes")
|
|
||||||
print(" Pulling and retrying push...")
|
|
||||||
if self._git_pull():
|
|
||||||
# Retry push after pull
|
|
||||||
retry_result = subprocess.run(
|
|
||||||
["git", "push"],
|
|
||||||
cwd=self.config.project_root,
|
|
||||||
capture_output=True,
|
|
||||||
text=True
|
|
||||||
)
|
|
||||||
if retry_result.returncode == 0:
|
|
||||||
print("✅ Pushed to remote after pull")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f"❌ Push failed after pull: {retry_result.stderr}")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
print("❌ Failed to pull before retry")
|
|
||||||
return False
|
|
||||||
else:
|
else:
|
||||||
print(f"⚠️ Push failed: {result.stderr}")
|
# If push failed due to remote changes, try pull and push again
|
||||||
|
if "fetch first" in result.stderr or "Updates were rejected" in result.stderr:
|
||||||
|
print("⚠️ Push rejected due to remote changes")
|
||||||
|
print(" Pulling and retrying push...")
|
||||||
|
if self._git_pull():
|
||||||
|
# Retry push after pull
|
||||||
|
retry_result = subprocess.run(
|
||||||
|
["git", "push"],
|
||||||
|
cwd=self.config.project_root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
if retry_result.returncode == 0:
|
||||||
|
print("✅ Pushed to remote after pull")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"❌ Push failed after pull: {retry_result.stderr}")
|
||||||
|
# Fall through to create PR (fallback)
|
||||||
|
else:
|
||||||
|
print("❌ Failed to pull before retry")
|
||||||
|
# Fall through to create PR (fallback)
|
||||||
|
|
||||||
|
# Fallback: create branch and PR
|
||||||
|
print("\n📝 Push to master failed, creating branch and PR as fallback...")
|
||||||
|
|
||||||
|
# Get current task
|
||||||
|
state = self.task_manager.load_state()
|
||||||
|
if not state.current_task_id:
|
||||||
|
print("⚠️ No current task ID, cannot create PR")
|
||||||
|
return False
|
||||||
|
|
||||||
|
tasks = self.task_manager.load_tasks()
|
||||||
|
current_task = next((t for t in tasks if t.id == state.current_task_id), None)
|
||||||
|
if not current_task:
|
||||||
|
print("⚠️ Current task not found, cannot create PR")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self._create_branch_and_pr(current_task)
|
||||||
|
else:
|
||||||
|
# We're already on a feature branch, just push it
|
||||||
|
print(f"\n📝 Pushing to feature branch: {current_branch}")
|
||||||
|
push_branch_result = subprocess.run(
|
||||||
|
["git", "push", "-u", "origin", current_branch],
|
||||||
|
cwd=self.config.project_root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if push_branch_result.returncode == 0:
|
||||||
|
print(f"✅ Pushed branch to remote")
|
||||||
|
|
||||||
|
# Try to create PR if we have task info
|
||||||
|
state = self.task_manager.load_state()
|
||||||
|
if state.current_task_id:
|
||||||
|
tasks = self.task_manager.load_tasks()
|
||||||
|
current_task = next((t for t in tasks if t.id == state.current_task_id), None)
|
||||||
|
if current_task:
|
||||||
|
self._create_pull_request(current_task, current_branch)
|
||||||
|
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Push failed: {push_branch_result.stderr}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _create_issue_for_failed_task(self, task: Task) -> None:
|
def _create_issue_for_failed_task(self, task: Task) -> None:
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,14 @@ class AgentConfig:
|
||||||
cursor_api_key: Optional[str] = None
|
cursor_api_key: Optional[str] = None
|
||||||
cursor_model: str = "claude-3-5-sonnet-20241022"
|
cursor_model: str = "claude-3-5-sonnet-20241022"
|
||||||
|
|
||||||
|
# Forgejo/GitHub API settings
|
||||||
|
forgejo_token: Optional[str] = None
|
||||||
|
forgejo_api_url: Optional[str] = None # Will be auto-detected from git remote
|
||||||
|
forgejo_repo: Optional[str] = None # Will be auto-detected from git remote
|
||||||
|
|
||||||
|
# Commit mode: 'master' (direct commit with PR fallback) or 'pr' (always create PR)
|
||||||
|
commit_mode: str = "master"
|
||||||
|
|
||||||
# State file paths
|
# State file paths
|
||||||
@property
|
@property
|
||||||
def agent_dir(self) -> Path:
|
def agent_dir(self) -> Path:
|
||||||
|
|
@ -70,6 +78,9 @@ class AgentConfig:
|
||||||
timeout_hours=int(os.getenv("TIMEOUT_HOURS", "6")),
|
timeout_hours=int(os.getenv("TIMEOUT_HOURS", "6")),
|
||||||
cursor_api_key=os.getenv("CURSOR_API_KEY"),
|
cursor_api_key=os.getenv("CURSOR_API_KEY"),
|
||||||
cursor_model=os.getenv("CURSOR_MODEL", "claude-3-5-sonnet-20241022"),
|
cursor_model=os.getenv("CURSOR_MODEL", "claude-3-5-sonnet-20241022"),
|
||||||
|
forgejo_token=os.getenv("FORGEJO_TOKEN"),
|
||||||
|
forgejo_api_url=os.getenv("FORGEJO_API_URL") or os.getenv("GITHUB_API_URL"),
|
||||||
|
commit_mode=os.getenv("COMMIT_MODE", "master"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate(self) -> None:
|
def validate(self) -> None:
|
||||||
|
|
|
||||||
110
tools/agent/doc_updater.py
Normal file
110
tools/agent/doc_updater.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
"""
|
||||||
|
Script to update documentation for a component using AI agent.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from config import AgentConfig
|
||||||
|
from cursor_cli_wrapper import CursorCLI, CursorResultStatus
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Update documentation for a component")
|
||||||
|
parser.add_argument("component", choices=["web_v2", "backend", "common"], help="Component to document")
|
||||||
|
parser.add_argument("--model", default="claude-3-5-sonnet-20241022", help="LLM model to use")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
try:
|
||||||
|
config = AgentConfig.from_env(args.component)
|
||||||
|
# Override model if provided in args (though config loads from env, we want to support CLI override if needed,
|
||||||
|
# but here we primarily rely on env vars set by workflow, so we'll stick to config or args)
|
||||||
|
# Actually, AgentConfig.from_env reads CURSOR_MODEL.
|
||||||
|
# If we pass it via args, we should update it.
|
||||||
|
if args.model:
|
||||||
|
config.cursor_model = args.model
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"❌ Configuration error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"🚀 Starting Documentation Update for: {args.component}")
|
||||||
|
print(f"🤖 Model: {config.cursor_model}")
|
||||||
|
|
||||||
|
# Initialize Cursor CLI
|
||||||
|
cursor_cli = CursorCLI(
|
||||||
|
project_root=config.project_root,
|
||||||
|
api_key=config.cursor_api_key,
|
||||||
|
model=config.cursor_model,
|
||||||
|
verbose=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define the task
|
||||||
|
task_description = f"""
|
||||||
|
You are an expert technical writer and software engineer.
|
||||||
|
Your task is to update the documentation for the '{args.component}' component.
|
||||||
|
|
||||||
|
1. Analyze the current codebase in '{config.component_root}'.
|
||||||
|
2. Check existing documentation in 'ai_docs/{args.component}' and 'README.md' files.
|
||||||
|
3. Identify missing or outdated documentation.
|
||||||
|
4. Update or create documentation files to reflect the current state of the code.
|
||||||
|
- Focus on architecture, API endpoints, setup instructions, and key features.
|
||||||
|
- Ensure 'README.md' in the component root is up to date.
|
||||||
|
- If there are significant changes, update 'ai_docs/{args.component}/architecture.md' or similar.
|
||||||
|
|
||||||
|
Do NOT modify any code files. ONLY modify markdown documentation files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
print("\n📝 Task Description:")
|
||||||
|
print(task_description)
|
||||||
|
print(f"\n{'='*80}\n")
|
||||||
|
|
||||||
|
# Run agent
|
||||||
|
result = cursor_cli.run_agent(
|
||||||
|
task_description=task_description,
|
||||||
|
force=True,
|
||||||
|
max_iterations=5
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.status != CursorResultStatus.SUCCESS:
|
||||||
|
print(f"\n❌ Documentation update failed: {result.error}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"\n✅ Agent completed successfully")
|
||||||
|
print(f" - Files created: {len(result.files_created)}")
|
||||||
|
print(f" - Files modified: {len(result.files_modified)}")
|
||||||
|
|
||||||
|
if not result.files_created and not result.files_modified:
|
||||||
|
print("\n⚠️ No changes made to documentation.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Commit changes
|
||||||
|
print("\n💾 Committing changes...")
|
||||||
|
|
||||||
|
# Git config
|
||||||
|
subprocess.run(["git", "config", "--global", "user.name", "AI Agent"], check=False)
|
||||||
|
subprocess.run(["git", "config", "--global", "user.email", "ai-agent@mnemo-cards.com"], check=False)
|
||||||
|
|
||||||
|
# Add changes
|
||||||
|
subprocess.run(["git", "add", "."], cwd=config.project_root, check=True)
|
||||||
|
|
||||||
|
# Commit
|
||||||
|
commit_msg = f"docs({args.component}): Update documentation via AI Agent\n\nModel: {config.cursor_model}"
|
||||||
|
subprocess.run(["git", "commit", "-m", commit_msg], cwd=config.project_root, check=False)
|
||||||
|
|
||||||
|
# Push
|
||||||
|
print("\n📤 Pushing changes...")
|
||||||
|
# Pull first
|
||||||
|
subprocess.run(["git", "pull", "--rebase"], cwd=config.project_root, check=False)
|
||||||
|
push_result = subprocess.run(["git", "push"], cwd=config.project_root, check=False)
|
||||||
|
|
||||||
|
if push_result.returncode == 0:
|
||||||
|
print("✅ Changes pushed successfully")
|
||||||
|
else:
|
||||||
|
print("❌ Failed to push changes")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in a new issue