diff --git a/.forgejo/workflows/agent-development.yml b/.forgejo/workflows/agent-development.yml index 82d4f2d..89ed211 100644 --- a/.forgejo/workflows/agent-development.yml +++ b/.forgejo/workflows/agent-development.yml @@ -11,6 +11,35 @@ on: - 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 + 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: # Run every 30 minutes - cron: '*/20 * * * *' @@ -76,11 +105,13 @@ jobs: - name: Run Development Agent env: CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} - CURSOR_MODEL: ${{ secrets.CURSOR_MODEL }} + CURSOR_MODEL: ${{ inputs.llm_model || secrets.CURSOR_MODEL }} PROJECT_ROOT: ${{ github.workspace }} MAX_ITERATIONS: 10 MAX_RETRIES: 3 TIMEOUT_HOURS: 6 + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + COMMIT_MODE: ${{ inputs.commit_mode || 'master' }} run: | # Verify cursor-agent is accessible echo "Current PATH: $PATH" diff --git a/.forgejo/workflows/agent-documentation.yml b/.forgejo/workflows/agent-documentation.yml new file mode 100644 index 0000000..bcb083d --- /dev/null +++ b/.forgejo/workflows/agent-documentation.yml @@ -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 }} diff --git a/.forgejo/workflows/agent-planning.yml b/.forgejo/workflows/agent-planning.yml index 3773e20..54e4c35 100644 --- a/.forgejo/workflows/agent-planning.yml +++ b/.forgejo/workflows/agent-planning.yml @@ -11,6 +11,27 @@ on: - 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 schedule: - cron: '0 */4 * * *' @@ -70,7 +91,7 @@ jobs: - name: Run Planning Agent env: CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} - CURSOR_MODEL: ${{ secrets.CURSOR_MODEL }} + CURSOR_MODEL: ${{ inputs.llm_model || secrets.CURSOR_MODEL }} PROJECT_ROOT: ${{ github.workspace }} MAX_ITERATIONS: 10 PATH: /usr/bin:/bin:$HOME/.local/bin:$HOME/.cursor/bin:${{ env.PATH }} diff --git a/ai_docs/agent/prompts/development_prompt.md b/ai_docs/agent/prompts/development_prompt.md index 1d93af6..1f46ccb 100644 --- a/ai_docs/agent/prompts/development_prompt.md +++ b/ai_docs/agent/prompts/development_prompt.md @@ -5,16 +5,46 @@ You are an autonomous AI software engineer working on the mnemo_cards project. Y ## Project Context 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_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 -1. **Read and understand the task** - Analyze the requirements and acceptance criteria -2. **Implement the solution** - Write clean, maintainable code following project conventions -3. **Write comprehensive tests** - Ensure all new code has unit tests -4. **Verify your work** - Make sure tests pass and linting is clean +1. **Read and understand the task** - Analyze requirements from `task.md` or user input. +2. **Manage State** - Create/Update `workflow_state.md`, `PROGRESS.md`, `TODO.md`. +3. **Implement the solution** - Write clean code, following `project_config.md`. +4. **Write comprehensive tests** - Unit tests are mandatory. +5. **Verify your work** - Tests, Lints, Auto-Debug. ## Code Standards @@ -244,12 +274,11 @@ try { ## Git Workflow Your commits will be automatically created. Make sure your changes are: -- **Atomic**: One logical change per commit -- **Complete**: All files needed for the feature -- **Tested**: All tests pass -- **Clean**: Linter passes -- **Size-limited**: Maximum 500 changed lines per commit - - If changes exceed this limit, split the work into multiple commits/tasks +- **Atomic**: One logical change per commit. +- **Complete**: All files needed for the feature. +- **Tested**: All tests pass. +- **Clean**: Linter passes. +- **Targeted**: Prefer surgical refactors over massive rewrites. ## Reference Materials diff --git a/ai_docs/agent/prompts/planning_prompt.md b/ai_docs/agent/prompts/planning_prompt.md index e54e829..2b7ac5e 100644 --- a/ai_docs/agent/prompts/planning_prompt.md +++ b/ai_docs/agent/prompts/planning_prompt.md @@ -60,6 +60,9 @@ Each task should have: **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** - **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 diff --git a/tools/agent/agent_orchestrator.py b/tools/agent/agent_orchestrator.py index c28c873..0c9f2fb 100644 --- a/tools/agent/agent_orchestrator.py +++ b/tools/agent/agent_orchestrator.py @@ -5,9 +5,13 @@ Executes a single task per run: read task -> execute -> test -> commit import sys import subprocess import time +import json +import re +import urllib.request +import urllib.error from pathlib import Path 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 task_manager import TaskManager, GlobalLock, Task @@ -293,15 +297,16 @@ class AgentOrchestrator: print("āš ļø No changes to commit") # Push changes (with pull before push to avoid conflicts) + # If push fails, _git_push() will automatically create a PR print("\nšŸ“¤ Pushing changes...") - if not self._git_push(): - print("āš ļø Push failed, pulling latest changes and retrying...") - # Pull latest changes before retrying push - if self._git_pull(): - print("āœ… Pulled latest changes, retrying push...") - self._git_push() - else: - print("āŒ Failed to pull latest changes") + push_success = self._git_push() + + if not push_success: + print("āš ļø Push failed, attempting to create PR...") + # _git_push() already handles PR creation, but if it still failed, + # we'll continue anyway as the changes are committed locally + print("āš ļø Changes are committed locally but not pushed") + print(" You may need to manually push or create a PR") return True @@ -488,8 +493,363 @@ Changes: # No changes or error 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: - """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 fetch_result = subprocess.run( ["git", "fetch"], @@ -518,41 +878,82 @@ Changes: if not self._git_pull(): print("āŒ Failed to pull, push may fail") - # Now try to push - result = subprocess.run( - ["git", "push"], - cwd=self.config.project_root, - capture_output=True, - text=True - ) - - if result.returncode == 0: - print("āœ… Pushed to remote") - return True - else: - # 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}") - return False - else: - print("āŒ Failed to pull before retry") - return False + # If we're on master, try to push directly + if current_branch == "master": + # Now try to push + result = subprocess.run( + ["git", "push"], + cwd=self.config.project_root, + capture_output=True, + text=True + ) + + if result.returncode == 0: + print("āœ… Pushed to remote") + return True 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 def _create_issue_for_failed_task(self, task: Task) -> None: diff --git a/tools/agent/config.py b/tools/agent/config.py index cb355c6..ea341a4 100644 --- a/tools/agent/config.py +++ b/tools/agent/config.py @@ -24,6 +24,14 @@ class AgentConfig: cursor_api_key: Optional[str] = None 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 @property def agent_dir(self) -> Path: @@ -70,6 +78,9 @@ class AgentConfig: timeout_hours=int(os.getenv("TIMEOUT_HOURS", "6")), cursor_api_key=os.getenv("CURSOR_API_KEY"), 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: diff --git a/tools/agent/doc_updater.py b/tools/agent/doc_updater.py new file mode 100644 index 0000000..529daec --- /dev/null +++ b/tools/agent/doc_updater.py @@ -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()